Skip to content

🤖 feat: Effect + oRPC integration spike (oRPC 1.14, effect v4-rc, handlerGen bridge) - #4022

Merged
ThomasK33 merged 9 commits into
mainfrom
effect-orpc-spike
Aug 31, 2026
Merged

ThomasK33 merged 9 commits into
mainfrom
effect-orpc-spike

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Aug 31, 2026 •

Copy link
Copy Markdown
Member

Summary

Spike evaluating progressive Effect adoption anchored on the oRPC layer, plus the dependency upgrades it forces. The Effect ↔ oRPC bridge (@orpc/experimental-effect) is wired into ORPCContext, one real leaf service (MemoryMetaService) and one real procedure chain (memory.setPinned) are converted to Effect.gen, and a validation namespace (effectSpike.*) proves typed error propagation, Effect Schema inputs, and abort-driven cancellation with exactly-once scope finalizers. Full findings + phased adoption plan: rfc/20260831_effect-orpc-spike.md.

Warning

Spike caveat, per the RFC's own recommendation: this PR adds effect@4.0.0-rc.112 (a pre-stable RC) as a production dependency and carries a local trpc-cli compatibility patch (no released trpc-cli supports oRPC 1.14). Merging adopts those risks on main; the conservative alternative is to land the oRPC 1.14 migration alone and hold the Effect bridge until v4 stabilizes. Merge-on-green was explicitly requested by @ThomasK33.

Background

We want typed error channels, structured concurrency (cancellation + resource scoping), and dependency-injected services for backend code. oRPC ships an official-but-experimental Effect integration; this spike answers whether it fits xum's existing router/context architecture and what incremental adoption would look like. Findings validated by tests in src/node/orpc/effectSpike.test.ts.

Dependency upgrades

Change Why
@orpc/{client,server,openapi,zod} ^1.11/^1.12 → 1.14.11 @orpc/experimental-effect exact-pins its oRPC deps at 1.14.11; mixed versions ship duplicate classes and break instanceof ORPCError
@orpc/experimental-effect@1.14.11 (new) The Effect bridge under evaluation (~40-line runtime; handlerGen, WithEffectContext, toStandardSchema)
effect@4.0.0-rc.112 (new) Peer requirement of the bridge (>=4.0.0-beta.90); v3 stable is not supported
patches/trpc-cli@0.12.1.patch (new) oRPC 1.14 removed isProcedure/traverseContractProcedures, which every released trpc-cli uses — xum api would crash at startup. Patch adds a local router walk, duck-typed procedure detection, and 1.14 def-shape shims (inputSchemas[])

oRPC 1.12→1.14 app migration included: @orpc/server/ws→/websocket, @orpc/zod/zod4→@orpc/zod, client fetch links split url into origin + path-only url, websocket links take connect: () => ws, OpenAPIGenerator converters/base options, ValidationError.invalidData, removed ORPCError.status (now derived from COMMON_ERROR_STATUS_MAP), instanceof Procedure, and createAuthMiddleware collapsed to a single typed middleware (1.14 .use() rejects unions of differently-typed middlewares).

Implementation

  • Bridge wiring: ORPCContext extends WithEffectContext<OrpcEffectServices>; ServiceContainer.toORPCContext() pre-builds the Effect service Context under "effect/context" (src/node/orpc/effectContext.ts). Handlers opt in per-procedure via handlerGen (no global Builder.prototype patching); async handlers coexist untouched.
  • Leaf-service conversion pattern: MemoryMetaService internals are Effect.gen with an Effect Semaphore write lock (interruption-safe) and a Schema.TaggedError (MemoryMetaWriteError) failure channel — behind an unchanged Promise facade, so all pre-existing callers and tests work unmodified.
  • Real procedure conversion: memory.setPinned → setMemoryPinnedEffect (Effect.gen; Effect.result/Effect.try replace try/catch; typed write failures map into the existing ResultSchema string error). Wire contract unchanged.
  • Validation namespace effectSpike.*: service injection via Effect context, Effect Schema input (toStandardSchema) coexisting with Zod outputs, Schema.TaggedError → defined oRPC error (.errors() map + catchTag, no untyped catch), scoped hold with acquire/release probe, and an async-vs-effect echo pair for overhead measurement (~3.4µs/call Effect overhead).

Validation

  • 7 new spike tests prove the headline claims: typed error round-trip with defined: true + schema-validated data; client abort interrupts the handler fiber promptly with the release finalizer running exactly once; Effect Schema inputs reject bad payloads as BAD_REQUEST.
  • Existing suites green after both the 1.14 migration and the Effect conversion: src/node/orpc/* (62), memoryMeta/memoryOperations, cli.test.ts/server.test.ts (31, exercising the patched trpc-cli end-to-end) — 125 pass / 3 skip / 0 fail.
  • make typecheck green on both tsconfigs; ESLint + prettier clean on touched files.
  • tests/ipc/streaming/queuedMessages.test.ts determinism fix (CI Test / Integration): oRPC 1.14's in-process iterator delivers queued-message-changed before the sendMessage promise resolves, so the suite's count-snapshot helper (wait for the next NEW event) already contained the queued event and observed the later drain [] instead — failing all 8 tests deterministically on this branch (confirmed via event-history dump: [[], ["Say 'SECOND'..."], []]). Replaced count-snapshot waits with predicate-based history scans (queued assertions) and latest-state convergence (drain waits). 2× 8/8 green locally against the live API. Supersedes the 🤖 tests: queuedMessages integration suite fails on unmodified main (queued-message-changed never observed) #4023 triage — that issue's "fails on unmodified main" local repro was contaminated by a provider-auth env issue.

Risks

  • Effect v4 RC churn (medium): APIs may shift before v4 stable; confined to MemoryMetaService, memory.setPinned, and the spike namespace — all revertible without wire-contract changes.
  • oRPC 1.14 behavioral surface (medium): the upgrade touches every RPC link (HTTP/WS, browser/CLI/ACP) and the OpenAPI generator. Covered by existing server/CLI/analytics tests, but transport edge cases (proxy prefixes, WS reconnects) get their real soak only in use.
  • trpc-cli patch (low): local patch must be maintained until upstream supports oRPC 1.14; cli.test.ts covers the patched paths.
  • memory.setPinned failure mode change (low, intentional): sidecar write failures now return {success:false, error} instead of an untyped INTERNAL_SERVER_ERROR rejection.

📋 Spike findings RFC (rfc/20260831_effect-orpc-spike.md)

author: @mux
date: 2026-08-31


Spike Findings: Progressive Effect Migration via oRPC Integration

Status: Spike report (branch effect-orpc-spike, not intended to merge as-is)

Objective

Evaluate a progressive migration of xum's backend to Effect,
anchored on the existing oRPC layer:

  1. Wire @orpc/experimental-effect into the oRPC context/router.
  2. Convert a representative leaf, I/O-heavy service and its procedures to Effect.gen.
  3. Validate Schema.TaggedError → typed oRPC error propagation without untyped catch blocks.
  4. Evaluate resource scoping (Scope/finalizers) and cancellation for long-lived operations.
  5. Recommend an incremental adoption architecture.

Everything below was validated by code in this branch (src/node/orpc/effectSpike.ts,
src/node/orpc/effectSpike.test.ts, converted MemoryMetaService/setMemoryPinned),
with all affected suites green: 128 tests across oRPC/memory/CLI files plus 7 new spike tests.

TL;DR

  • The integration works and is remarkably small. @orpc/experimental-effect is a
    ~40-line runtime bridge: handlers become generators that yield* Effects, services are
    injected via a pre-built Context on the oRPC context, AbortSignal maps to fiber
    interruption, and ORPCError instances in the failure channel become typed, defined
    oRPC errors. Typed error propagation, scoped finalizers under client aborts, and Effect
    Schema inputs all behave exactly as advertised.
  • The version prerequisites are the real cost. The official package requires
    Effect v4 (currently RC) and pins oRPC 1.14.x exactly. Migrating xum from oRPC
    1.12→1.14 was bounded (~12 files, this branch did it) but breaks trpc-cli's oRPC
    support (fixed here with a bun patch). Effect v4 RC is pre-stable.
  • Overhead is negligible for xum's workloads: ~3–4µs per call added by the Effect
    runtime on a no-op procedure (in-process router client, Bun; 8.8µs/call async vs
    12.2µs/call Effect). Any real I/O dwarfs this.
  • Recommendation: adopt in narrow slices, gated on Effect v4 stable. The
    service-internal conversion pattern (Effect core + thin Promise facade) is immediately
    usable and low-risk; the oRPC bridge layer is a one-file change once versions align. If
    we want to start before v4 stabilizes, a hand-rolled handlerGen (the bridge is ~40
    lines, MIT) against stable Effect 3.x is a viable interim path.

What was built

1. Bridge wiring (objective 1)

  • ORPCContext now extends WithEffectContext<OrpcEffectServices>: one well-known key,
    "effect/context", carrying a pre-built Context.Context of Effect services
    (src/node/orpc/effectContext.ts, built in ServiceContainer.toORPCContext()).
  • Handlers use handlerGen directly (.handler(handlerGen(function* ({ context, errors, signal }, input) { ... }))).
    The .effect() builder sugar exists but requires a side-effect import that patches
    Builder.prototype globally; handlerGen has zero global footprint and was preferred.
  • The optional "effect/wrap" hook wraps every Effect handler per request with
    { path, procedure, signal } — the natural future seam for tracing/metrics
    (@effect/opentelemetry) without touching individual handlers.

2. Leaf service conversion (objective 2)

MemoryMetaService (host-local JSON sidecar; pure disk I/O, one write lock) was converted:

  • Internals are Effect.gen; the promise MutexMap became an Effect Semaphore, so lock
    acquisition participates in interruption (a fiber cancelled while waiting never runs its
    critical section — a real correctness upgrade over the promise mutex).
  • The public API is preserved by a thin Promise facade (Effect.runPromise per method)
    plus a new Effect-native effects surface for migrated callers. All 12 pre-existing
    service tests pass unchanged — the facade pattern demonstrably de-risks conversion.
  • One real procedure chain was converted end-to-end: memory.setPinned →
    setMemoryPinnedEffect (Effect.gen with Effect.result/Effect.try replacing try/catch)
    → handlerGen in the router. The zod wire contract is untouched.

3. Typed errors (objective 3)

MemoryMetaWriteError is a Schema.TaggedError — simultaneously an Error subclass, a
schema, a tagged-union member, and yieldable. The spike procedure declares
.errors({ MEMORY_META_WRITE_FAILED: { data: z.object({ metaPath, reason }) } }) and maps:

yield *
  memoryMeta.effects
    .setPinned(key, pinned)
    .pipe(
      Effect.catchTag("MemoryMetaWriteError", (e) =>
        Effect.fail(
          errors.MEMORY_META_WRITE_FAILED({
            data: { metaPath: e.metaPath, reason: e.reason },
          }),
        ),
      ),
    );

Validated: the client receives ORPCError with code: "MEMORY_META_WRITE_FAILED",
defined: true, and schema-validated data. No untyped catch anywhere on the path — the
failure is typed from writeFileAtomic all the way to the wire.

Caveat found: the bridge's types do not force exhaustive error handling. Yielded
effects may carry any E; only ORPCError members become typed returns, and everything
else silently remains a runtime throw (squashed cause), exactly like today's untyped
rejections. Exhaustiveness is opt-in: teams must adopt a convention (or a lint) that
handler-yielded effects satisfy E extends AnyORPCError | never. Worth building a tiny
yieldStrict helper or ESLint rule during real adoption.

4. Scoping + cancellation (objective 4)

effectSpike.scopedHold acquires a resource with Effect.acquireRelease inside
Effect.scoped and sleeps. Validated by test:

  • Client AbortController.abort() interrupts the fiber promptly (60s hold aborted in
    <5s wall including test overhead; actual interruption is immediate).
  • The release finalizer runs exactly once, both on abort and on normal completion.
  • The bridge maps interruption back to the abort reason (options.signal.reason), so oRPC
    reports the abort exactly like an async handler would.

This is the headline win for xum: today's long-lived operations thread AbortSignal
manually through every layer (e.g. cloneWithProgress(input, signal)) and clean up in
ad-hoc try/finally. Structured concurrency makes "cancellation propagates + resources
release" the default instead of a per-call discipline. Prime future candidates:
routerSubscriptions.ts teardown, process spawns, MCP server lifecycles, stream managers.

5. Performance & ergonomics (objective 5)

  • Micro-benchmark (in-process router client, 2k sequential calls each, warmed):
    no-op async handler ≈ 8.8µs/call; identical handlerGen handler ≈ 12.2µs/call;
    Effect runtime overhead ≈ 3.4µs/call. Noise-level for anything touching disk,
    network, or an LLM.
  • Ergonomics that worked well: yield* reads like await; service tags double as
    Effects (const svc = yield* MemoryMeta); catchTag gives compiler-checked error
    narrowing; existing zod schemas coexist with Effect Schema inputs per-procedure
    (toStandardSchema), so there is no forced schema migration.
  • Frictions: this is not available inside Effect.gen generators (self-alias needed,
    plus an eslint-disable for no-this-alias); sync throws inside generators become
    defects rather than typed failures (fine for ORPCError gates like
    assertMemoryEnabled, but a subtle trap); require-yield lint fires on yield-free
    generator handlers; Effect v4 renames significant v3 API (Effect.catch,
    Context.Service, Semaphore.make*, Effect.result/Result), so most public
    Effect v3 documentation and LLM training data does not directly apply.

Version compatibility findings (the hard constraints)

Constraint Detail
@orpc/experimental-effect peer effect >= 4.0.0-beta.90 — Effect v4 only (v4 is at RC today; v3 stable is not supported)
@orpc/experimental-effect deps Exact-pinned @orpc/{server,shared,contract,json-schema}@1.14.11 — repo must move to oRPC 1.14 in lockstep or ship duplicate oRPC copies (breaks instanceof ORPCError and prototype patches; bun kept stale nested copies until a forced reinstall)
oRPC 1.12→1.14 breaks @orpc/server/ws→/websocket; @orpc/zod/zod4→@orpc/zod; client links split url into origin + path-only url; websocket links take connect: () => ws; OpenAPIGenerator options (converters, base); ValidationError.data→invalidData; ORPCError.status removed; isProcedure/traverseContractProcedures removed (use instanceof Procedure); procedure defs store inputSchemas[] + orderedMiddlewares; .use() no longer accepts unions of differently-typed middlewares
Ecosystem fallout No released trpc-cli (≤0.16.0) supports oRPC 1.14 — xum api would crash at startup. This branch carries a bun patch (patches/trpc-cli@0.12.1.patch: local router traversal + duck-typed isProcedure + def-shape shims). Upstreaming or replacing trpc-cli is a prerequisite for a real upgrade.
Package maturity The experimental- prefix is explicit; v1 line exists (1.14.11) plus 2.0.0 betas tracking oRPC v2. API surface is tiny, so forking/vendoring is a realistic escape hatch.

Recommended incremental adoption architecture

Phased, each phase independently shippable and reversible:

Phase 0 — prerequisites (before any Effect code ships):
oRPC 1.14 upgrade as its own PR (this branch's first commit is that migration, validated);
resolve trpc-cli (upstream a 1.14-compat PR, keep the bun patch, or replace with a thin
custom CLI walker — we already own proxifyOrpc). Hold production Effect adoption until
Effect v4 stable unless we vendor a v3-compatible handlerGen (~40 lines).

Phase 1 — services adopt Effect internally (no oRPC coupling, can start anytime):
convert leaf, I/O-heavy services with the pattern proven here — Effect-native effects
surface + Promise facade, existing tests untouched. Best next candidates:
HistoryService (locks + atomic writes + self-healing reads), config stores,
workspaceFileLocks users. Each conversion upgrades error typing and interruption safety
without any caller changes.

Phase 2 — bridge layer on (one-file switch):
ORPCContext extends WithEffectContext<...> + ServiceContainer builds the service
Context (done in this branch). New/converted procedures use handlerGen; the rest stay
async. Both styles coexist indefinitely — this is the core progressive-migration property.

Phase 3 — typed errors at the wire:
migrate high-value procedures from ResultSchema({success:false,error:string}) toward
.errors() maps fed by Schema.TaggedError (isDefinedError gives clients typed
narrowing). Do this per-procedure; frontend consumes error.defined/error.data.
Adopt an exhaustiveness convention/lint for handler E channels (see caveat above).

Phase 4 — structured concurrency for long-lived ops:
move subscriptions (routerSubscriptions.ts), process spawns, and stream lifecycles onto
Scope/acquireRelease, replacing manual AbortSignal threading. Add an
"effect/wrap" hook for tracing/metrics across all Effect handlers.

Non-goals for now: full Layer-based dependency graph (ServiceContainer already does
this job; revisit only if service construction becomes Effect-native), Effect on the
renderer/browser side, and Effect Schema replacing zod wholesale (coexistence works).

Key risks

  1. Effect v4 RC churn — APIs may still shift before stable; don't merge v4 to main yet.
  2. Two mental models during migration — mitigated by the facade pattern (callers never
    see Effect until their own conversion) and by keeping handlerGen opt-in per procedure.
  3. Untyped-failure escape hatch — the bridge tolerates non-ORPCError failures silently
    (runtime throw); needs a lint/convention to realize the "no untyped errors" promise.
  4. Ecosystem lag on oRPC 1.14 (trpc-cli today; audit other oRPC-adjacent deps before
    upgrading main).

Validation record

  • make typecheck green (both tsconfigs).
  • bun test src/node/orpc/ src/node/services/memoryMeta.test.ts src/node/services/memoryOperations.test.ts src/cli/cli.test.ts src/cli/server.test.ts → 125 pass / 3 skip / 0 fail.
  • bun test src/node/orpc/effectSpike.test.ts → 7/7: service injection, Effect Schema
    validation, typed error round-trip (success + failure with defined:true + data),
    abort-interruption with exactly-once finalizers, normal-completion finalizers, benchmark.
  • ESLint clean on all touched files.

Generated with xum • Model: anthropic:claude-fable-5 • Thinking: xhigh • Cost: $35.81

…mental-effect

- Bump @orpc/* to 1.14.11 (lockstep with @orpc/experimental-effect's exact pin)
- Adapt renamed subpaths (/ws→/websocket, zod/zod4→zod), OpenAPIGenerator
  options (converters, base), ValidationError.invalidData, removed
  ORPCError.status, client link origin/url split and websocket connect factory
- Collapse createAuthMiddleware union into one typed middleware (1.14 .use()
  no longer accepts differently-typed middleware unions)
- Patch trpc-cli 0.12.1 for oRPC 1.14 (removed isProcedure/
  traverseContractProcedures exports; inputSchemas array)
- proxifyOrpc: handle 1.14 def shape (inputSchemas, orderedMiddlewares)
- MemoryMetaService: Effect-native internals (Semaphore write lock,
  Schema.TaggedError MemoryMetaWriteError) behind an unchanged Promise facade
- setMemoryPinned: converted to Effect.gen; router runs it via handlerGen with
  the existing zod wire contract
- ORPCContext extends WithEffectContext; ServiceContainer pre-builds the
  Effect service Context under 'effect/context'
- effectSpike.* namespace: service injection, Effect Schema input, typed
  error propagation (.errors + catchTag, no untyped catch), scoped
  cancellation probe, async-vs-effect echo benchmark
- effectSpike.test.ts validates all of the above (7 tests)
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fecd704ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/orpc/effectSpike.ts
@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: a4c9d15356

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

…t analytics error assertion

- babel transform-import-meta-for-jest now also rewrites OptionalMemberExpression
  (effect's ConfigProvider uses `...import.meta?.env`), unblocking Jest suites
- useAnalytics test asserts the no-detail-leak contract case-insensitively
  (oRPC 1.14 changed default INTERNAL_SERVER_ERROR message casing)
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48fca8634f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/memoryMeta.ts Outdated
@chatgpt-codex-connector

This comment has been minimized.

Fiber interruption during writeFileAtomic could release the permit while the
non-cancellable write continued, leaving the in-memory cache stale relative to
disk; the next mutation would then rebuild disk from the stale cache and lose
the interrupted write. The write + cache assignment now run in one
Effect.uninterruptible unit. Adds an interruption-race regression test
(red-checked against the unfixed implementation).
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65a2cd3d9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/cli/proxifyOrpc.ts
Comment thread src/node/orpc/router.ts Outdated
Comment thread package.json
- Remove effectSpike namespace from the production router; tests mount it
  behind the same auth middleware composition instead (codex P2)
- Migrate the two remaining RPCLink consumers missed by the 1.14 upgrade:
  vscode extension api client (origin/url split + new fetch override
  signature) and scripts/smoke-test.sh (codex P1)
- Export createOpenAPIGenerator so the Effect-schema converter regression
  test validates the production converter set without prod-mounting the spike
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 887674fa32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

The suite's waitForQueuedMessageEvent counted events at call time and
returned the next NEW event. oRPC 1.14's in-process iterator delivers the
queued-message-changed event before the sendMessage promise resolves, so
the snapshot already contained it and the helper observed the later
queue-drain event ([]) instead — failing all 8 tests deterministically.

Replace count-snapshot waits with predicate-based history scans (queued
assertions) and latest-state convergence (drain waits). Verified 2x green
locally with live API; root cause confirmed by event-history dump:
[[], ["Say 'SECOND' and nothing else"], []]. Closes the investigation
from #4023 (the 'fails on unmodified main' local repro was contaminated
by a bridge-auth env issue).
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 57f03bba0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33
ThomasK33 added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 69a39ea Aug 31, 2026
20 checks passed
@ThomasK33
ThomasK33 deleted the effect-orpc-spike branch August 31, 2026 17:43
@mux-bot mux-bot Bot mentioned this pull request Sep 1, 2026
asm pushed a commit to asm/mux that referenced this pull request Sep 2, 2026
…e + Stores/MemoryMeta layers + runtime-backed effect/context) (coder#4049)

## Summary

Effect migration **Wave 3 / Phase 11, PR 1 of 6 (skeleton)**: introduces
the app-lifetime Effect `ManagedRuntime` ("AppRuntime") built from a
Layer graph, with the first two layers (`StoresLive` exposing the
`ConfigStores`, `MemoryMetaLive` constructing `MemoryMetaService`), and
wires it into `ServiceContainer`: the runtime is built eagerly and
synchronously before the constructor-wired services, its built `Context`
becomes the oRPC `"effect/context"`, and `disposeAppRuntime` (bounded,
never rejects, idempotent) is the last `dispose()` step. Every service
class, constructor signature, facade, and test seam is unchanged;
existing tests are untouched and green.

## Background

Two migration waves (#4022→#4040) converted service internals to Effect
behind Promise facades. #4040's completion notes name a DI/runtime
skeleton as the prerequisite for the streamManager engine-core
conversion (needs an app-owned async `Scope.close`), `TestClock` for
timing suites, and app-lifetime scopes. The approved Phase 11 plan
(embedded below) phases that into six PRs; this is the smallest possible
first step that proves the pattern end-to-end with tests unchanged.

## Implementation

- `src/node/services/di/tags.ts` — `Context.Service` tags (`ConfigTag`,
`SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`,
`FileLeaseManagerTag`, `MemoryMeta` moved here from
`orpc/effectContext.ts`, which re-exports it) + the `AppTags` union.
Type-only imports of service classes → no import cycles.
- `di/layers/stores.ts` (`StoresLive`), `di/layers/core.ts`
(`MemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c =>
new MemoryMetaService(c.rootDir)))`), `di/layers/app.ts`
(`AppLive(stores) =
MemoryMetaLive.pipe(Layer.provideMerge(StoresLive(stores)))`).
- `di/appRuntime.ts` — `makeAppRuntime(layer)`: `ManagedRuntime.make` +
eager `runSync(Effect.context())` + `assert(cachedContext)`; a layer
body that suspends or throws fails **at construction**, exactly where a
throwing service constructor fails today (so every entry point's
existing startup catch path applies). `disposeAppRuntime(runtime,
timeoutMs)`: uninterruptible shell, `disposeEffect` forked detached,
interruptible bounded `Fiber.join` + `Effect.timeout`,
`TimeoutError`/defects folded to `log.warn`. The module doc comment
carries the DI contract (sync layer bodies as a Phase 11 compatibility
rule; only the composition root holds the runtime; no layer finalizers
yet).
- `ServiceContainer`: `public readonly runtime: AppRuntime<AppTags>`
built first; the layer-built `MemoryMetaService` is handed to
`createCoreServices` via a new optional `memoryMetaService?` (same
precedent as `workspaceMcpOverridesService?`);
`toORPCContext()["effect/context"] = runtime.context`; `dispose()` ends
with `disposeAppRuntime` behind a `runtimeDisposed` latch.
- `orpc/effectContext.ts`: `OrpcEffectServices = AppTags`;
`buildOrpcEffectContext` stays as the narrow test helper it already is
(only caller: `effectBridge.test.ts`).
- `headlessEnvironment.dispose` now calls `services.dispose()` (the
bench harness previously leaked the container; runtime ownership starts
here).
- `APP_RUNTIME_DISPOSE_TIMEOUT_MS = 2 s` in
`src/constants/terminationTimeouts.ts` (inside the 5 s quit budgets of
`desktop/main.ts` / `cli/server.ts`).

## Validation

New tests: `di/appRuntime.test.ts` (sync build caches context; async
layer body → synchronous throw; throwing body → synchronous throw;
`runFork` after eager build starts synchronously; finalizers run in
reverse acquisition order; dispose idempotent; hung finalizer → returns
at timeout with a warn, no rejection) and three
`serviceContainer.test.ts` cases (field ↔ `effect/context` identity for
`MemoryMeta`; runtime still alive at the last explicit dispose step and
gone after; a throwing layer surfaces as a synchronous `new
ServiceContainer()` throw). `effectBridge.test.ts` /
`memoryMeta*.test.ts` unchanged and green.

Pre-review audits (plan §3): interruption posture — one detached fiber
(`disposeEffect`), whose join is the only interruptible wait; teardown
shell `Effect.uninterruptible`; `makeAppRuntime` is the single place
allowed to throw and `disposeAppRuntime` folds `TimeoutError` + defects;
spy seams — no `spyOn` targets `MemoryMetaService`, constructor arity
unchanged (`(xumHome)`), tests typecheck; sync-start pinned by test;
`MemoryMetaService` constructor only computes a path (no collaborator
side effects).

### Dogfooding (dev-server sandbox on a headless Coder host;
`XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS="--clean-projects"`)

- **Startup ordering** (branch): `[startup] AppRuntime built { ms: 3 }`
→ `[startup] ServiceContainer.initialize starting` → six step durations
→ `[startup] ServiceContainer.initialize completed { totalMs: 251 }`.
Baseline `origin/main` standalone `xum server`: `initialize completed {
totalMs: 271 }` (workspaceService 82 / taskService 104 ms). The added
constructor work is the 3 ms build.
- **Memory pin/unpin through the UI** (Settings → Experiments → Agent
Memory on; Settings → Memory; seeded
`<XUM_ROOT>/memory/global/pr1-dogfood.md`): Pin → `memory-meta.json`
shows `"pinned": true, "accessCount": 1`; Unpin → `"pinned": false`.
These `memory.*` procedures ride `handlerGen`; they use the transitional
`context.memoryMetaService.effects…` style, so this proves the
**layer-built instance** serves the handler path (tag resolution through
`effect/context` itself is proven by `effectBridge.test.ts` + the
identity test). Screenshots (`03-memory-experiment-enabled`,
`04-memory-panel`, `05-memory-pinned`, `06-memory-unpinned`) and a WebM
of the full pin/unpin cycle were captured with agent-browser and are
attached in the Mux chat transcript — GitHub image upload is unavailable
from this host (SSO upload-token failure).
- **Graceful quit** (standalone `node dist/cli/index.js server` under
`script -q`, `kill -TERM`): `Shutting down server...` →
`AgentStatusService stopped` → `terminateAll` → `[shutdown] AppRuntime
disposed { ms: 2 }` → `COMMAND_EXIT_CODE="0"`, no "Cleanup timed out".
Repeated after the probe below was reverted (clean rebuild): same
result.
- **Startup-never-crash parity probe** (local edit, not committed): a
`Layer.sync(MemoryMeta, () => { throw … })` in `AppLive` → `Failed to
initialize server: Error: PR1 dogfood probe: layer body threw during
startup` via the existing `main().catch` → exit code 1, stack points at
the layer body, **no** unhandled-rejection trace, no `AppRuntime
built`/`initialize` lines (failed at construction, as designed). The
sandbox's nodemon showed `app crashed - waiting for file changes`,
identical to a throwing constructor.
- **Electron path**: not exercised here (no display); the desktop quit
path shares `ServiceContainer.dispose()` with `cli/server.ts`, which was
exercised above, and `tests/e2e` covers it in CI.

### Test lanes

`make static-check` green. `bun test src/node/services/di
src/node/services/serviceContainer.test.ts src/node/orpc
src/node/services/memoryMeta*` green. Full `bun test src` under host
load wedged in `workflow_run.test.ts` (unrelated tool test); every
unexpected `(fail)` in that lane either passes in isolation
(`workflow_run` 25/25, `workspaceGoalService` 189/189,
`WorkspaceFooterBar` 15/15) or fails identically on an `origin/main`
worktree (`workspaceTurnManager` 2, `productIdentity` 1,
`agent_skill_delete` 1 — pre-existing environment failures, alongside
the known `taskService`/`workspaceService` baselines). jest lane
(`TEST_INTEGRATION=1 bun x jest tests`, tests/ipc + tests/ui): see the
CI checks / the run summary in the notes below.

## Risks

Low. Behavior change is confined to: (1) `MemoryMetaService` is
constructed by a layer instead of `createCoreServices` (same arguments,
same single instance — asserted by identity tests), (2)
`"effect/context"` carries six tags instead of one (only `effectBridge`
probes yield tags today), (3) `dispose()` gains a final bounded runtime
close (no layer finalizers exist yet, so it reorders nothing), (4) the
headless bench harness now disposes its container. The remaining risk is
the sync-layer contract itself; it is enforced at construction and
covered by tests.

## PR 1 notes

- **Echo-probe overhead** (`effectBridge.test.ts` informational bench,
2000 sequential calls, three runs each): branch — effect 24.3 / 21.9 /
22.0 µs/call, overhead vs plain async 8.3 / 8.3 / 7.9 µs/call;
`origin/main` — effect 18.5 / 26.9 / 23.0 µs/call, overhead −1.5 / 4.0 /
10.0 µs/call. Noise-level identical; note the probe uses the test's
narrow `buildOrpcEffectContext` context on both sides, and the
production context has six entries in PR 1.
- **Deviations from the plan** (all within the plan's stated contract):
1. `disposeAppRuntime` bounds the wait by forking `disposeEffect`
detached and timing out the interruptible `Fiber.join`, rather than
`Effect.timeout` directly on `disposeEffect`: scope finalizers run
uninterruptibly, so interrupting the close itself would still wait on a
hung finalizer. The contract (bounded, never rejects, idempotent) is
what the tests pin.
2. `AppRuntime<R>` is a small interface `{ managed, context, get }` and
`ServiceContainer.runtime` is that wrapper; `"effect/context"` is
`runtime.context` (the plan's `serviceContext`). Keeps `Context.get`
inside `di/`.
3. The latch guards only the runtime-dispose step, not all of
`dispose()`, so existing dispose semantics are byte-identical.
4. The `[startup] AppRuntime built` debug line lives in `makeAppRuntime`
(shared by the future CLI root in PR 3) instead of `ServiceContainer`.
- **Lessons for PR 2 (EffectRunner + AppFiberScope + TestClock on
idleCompaction/heartbeat/retryManager)**:
- rc.112 API notes: `Layer.succeed` is curried-only
(`Layer.succeed(Tag)(value)`); `ManagedRuntime<R, ER>` is contravariant
in `R`, so helpers accepting any runtime must take
`ManagedRuntime<never, never>`; `Effect.timeout` fails with
`Cause.TimeoutError` (`_tag: "TimeoutError"`); `Effect.runSync` really
does throw on an `Effect.promise` inside a layer body, so the
eager-build assert is a belt-and-braces check.
- Bounded teardown shape that actually bounds:
`Effect.forkDetach(target)` +
`Effect.interruptible(Fiber.join(fiber).pipe(Effect.timeout(ms)))`
inside `Effect.uninterruptible`. Reuse for
`closeScopeBounded(appFiberScope)`.
- Bun `spyOn(namespaceImport, "AppLive")` intercepts
`ServiceContainer`'s named import (live binding) — a cheap way to inject
test layers/probes without new production seams; PR 2's `EffectRunner`
tests can use the same trick for `AppLive`/`EffectRunnerLive`.
- Dev-server sandbox is fragile across a crash cycle (its build watcher
died after the probe; nodemon stayed in "app crashed"); for
startup/shutdown probes prefer a standalone `node dist/cli/index.js
server` under `script -q` with a temp `XUM_ROOT` — it also yields the
exit code.
- Full `bun test src` can wedge under host load (a `workflow_run`
duplicate-guard test hung without a timeout); classify unexpected
failures by re-running in isolation and against an `origin/main`
worktree with a symlinked `node_modules` (~15 s per file) rather than
waiting on the lane.

---

<details>
<summary>📋 Implementation Plan</summary>

# Effect migration — Wave 3 / Phase 11: ManagedRuntime + Layer
dependency injection

## 0. Summary

Replace the two hand-written composition roots (`createCoreServices` +
the `ServiceContainer` constructor) with an **Effect `Layer` graph**
built once per process by a **`ManagedRuntime`** ("AppRuntime"), while
keeping every service class, constructor signature, Promise facade,
private method, and test seam compatible. The runtime becomes (a) the
owner of the app-lifetime `Scope`, (b) the provider of
`"effect/context"` for oRPC Effect-native handlers, and (c) the source
of two runtime seams: an **`EffectRunner`** (context-bound,
*unsupervised* runner that lets clock-driven workers run on a
`TestClock`) and an **`AppFiberScope`** (a runtime-owned, *supervised*
scope whose close is awaited by `dispose()` — the slot the streamManager
engine core will occupy later).

Six stacked, independently mergeable PRs. Product PRs keep existing
tests unchanged; only the final test-modernization PR edits tests. Net
product LoC ≈ **+420** (per-PR estimates below). Service classes are
*not* rewritten — Layers are thin adapters around existing constructors;
cycle-breaking setter wiring moves into explicit "wiring layers" that
replay today's order.

Unlocks (not done here): streamManager ENGINE CORE conversion,
`TestClock` for timing suites, app-lifetime scopes.

## 1. Verified current state (evidence)

- **Roots.** `src/node/services/coreServices.ts:103-389`
(`createCoreServices`: 25 constructions, 12 `turnRequestBuilderBindings`
writes, ~14 setters) and `src/node/services/serviceContainer.ts:161-575`
(45 more constructions; `aiService.on(...)`/`workspaceService.on(...)`
analytics wiring at 474-574; global registrations
`setGlobalCoderService/setSshPromptService` at 469-471). `new
ServiceContainer(stores)` is called by `headlessEnvironment.ts:111`,
`tests/ipc/setup.ts`, `src/cli/server.ts:132`,
`src/node/acp/serverConnection.ts:155`, `src/desktop/main.ts:653`;
`src/cli/run.ts:661` and `src/cli/workflow.ts:376` call
`createCoreServices` directly. ⇒ two graph roots (App vs Core), five
process entry points, all constructing **synchronously**.
- **Startup.** `ServiceContainer.initialize()` (577-642) awaits six
`initialize()`s (no try/catch; failure propagates to `main.ts:1255-1265`
"Startup Failed" dialog + quit; `server.ts`/ACP log and exit), then sync
`start()`s idleCompaction/heartbeat/agentStatus, then two
fire-and-forget sweeps. All constructors are synchronous; two have side
effects on **declared constructor dependencies** only (`AIService` →
`streamManager.setEventSink`, `WorkspaceService` →
`backgroundProcessManager.on/aiService.on`).
- **Teardown.** `dispose()` (746-779) is explicit and hand-ordered
(`backgroundProcessManager.beginShutdown()` MUST be first — it is a
latch protecting persisted monitor records; bridges stop before sessions
close; `terminateAll` late; `timelineService.flush()` last).
`shutdown()` (718-732) is a *second* sequence fired concurrently by a
second `before-quit` listener (`main.ts:1321`). `main.ts:1296-1304`
races `dispose()` against 5 s then `app.quit()`; `cli/server.ts:227-268`
has a 5 s `process.exit(1)` force timer; `tests/ipc` cleanup calls
`dispose()` then `shutdown()`; `headlessEnvironment.dispose` never calls
`services.dispose()`.
- **Existing Effect surface.** 25 files import `effect`. Only
`Context.Service` tag: `MemoryMeta`
(`src/node/orpc/effectContext.ts:21`). `handlerGen`
(`@orpc/experimental-effect`) runs `Effect.runPromiseExit` per request
and `Effect.provide`s `opts.context["effect/context"]`.
`streamBridge.ts` runs streams on the global runtime. Scope-owning
workers: `heartbeatService.ts:134-243`,
`idleCompactionService.ts:86-122` (`Scope.makeUnsafe` +
`Effect.runSync(Scope.close(..))`, valid only because their fibers
suspend solely on the clock), `oauthFlowManager.ts:164`,
`streamManager.ts:4767/4054` (already `Effect.runFork(Scope.close(..))`
— the async-close precedent). `memoryConsolidationService.ts:667-703,
837-860`: check-and-reserve funnels with zero suspensions before
`inFlight.set`/`harvestInFlight.set`.
- **effect@4.0.0-rc.112 API (verified in `node_modules/effect/dist`).**
`Context.Service<Self, Shape>()("id")` (module `Context`, not
`ServiceMap`);
`Layer.{succeed,sync,effect,effectContext,effectDiscard,provide,provideMerge,mergeAll,build,buildWithScope}`
(no `Layer.scoped`; `Layer.effect` strips `Scope` from R);
`ManagedRuntime.make(layer)` → `{ runSync, runSyncExit, runFork,
runPromise, runPromiseExit, contextEffect, cachedContext, scope,
dispose(), disposeEffect }`;
`Effect.{runSyncWith,runForkWith,runPromiseWith,runPromiseExitWith}(context)`;
`Effect.context<R>()`; `Effect.serviceOption`;
`Scope.{fork,forkUnsafe,close,provide}`; `TestClock` from
`effect/testing` (`layer, adjust, setTime, withLive`); `Clock.Clock` is
a `Context.Reference` (defaulted; `TestClock.layer()` overrides it).
- **ManagedRuntime internals the design relies on**
(`ManagedRuntime.js`): `make` creates `scope =
Scope.makeUnsafe("parallel")` and `layerScope = Scope.forkUnsafe(scope,
"sequential")`; the first `runX` forks a build fiber over
`Layer.buildWithMemoMap` — a **fully synchronous layer graph builds
synchronously**, so `runtime.runSync(Effect.context())` succeeds and
sets `cachedContext`; afterwards every `runX` is
`Effect.run…With(cachedContext)` (no extra async boundary). Fibers
started through `runtime.runX` are registered in `scope` (`onFiberStart:
Fiber.runIn(scope)`). `dispose()` = `Scope.close(scope)` (interrupt
registered fibers in parallel → layer finalizers sequentially in
reverse), after which any `runtime.runX` dies with `"ManagedRuntime
disposed"`.
- **Layer composition semantics.** `Layer.mergeAll(A, B)` is *not* a
dependency resolver: B's requirements are not satisfied by A's outputs;
requirements bubble up. Dependencies are satisfied only via
`Layer.provide`/`provideMerge` chains. Siblings in `mergeAll` may build
concurrently.
- **Test seams that pin signatures** (Explore report): private-method
spies (`Config.saveConfig`,
`WorkspaceService.retireKernelWorkflowRunReferences/startStartupRecovery/createSession/updateAgentStatus`,
`MCPServerManager.startServers`,
`AgentPluginInstallService.reconcileJournals`, …); module-level export
spies (`agentStatusService.generateWorkspaceStatus`,
`sshConnectionPool.verifyHostKeyAgainstPolicyEffect`, …); direct
construction in tests (`Config` 44 files, `HistoryService` 22,
`MemoryMetaService` 11, `WorkspaceService` 7, `IdleDispatcher` 6,
`StreamManager` 4, `ServiceContainer` 3); partial-mock casts
(`InitStateManager` 193, `AIService` 158, `TaskService` 149,
`ORPCContext` 62). `effectBridge.test.ts:24-30` builds a partial
`ORPCContext` via `buildOrpcEffectContext` + `as unknown as
ORPCContext`.
- **Timing probes** (TestClock candidates): `heartbeatService.test.ts` 6
real sleeps, `idleCompactionService.test.ts` 2, `retryManager.test.ts` 3
`setSystemTime`, `streamManager.test.ts` 7 (partial-write debounce),
`streamBridge.test.ts` 11 (heartbeat ticker), OAuth device-flow suites
14 (non-goal).

## 2. Target architecture

### 2.1 Building blocks (all under `src/node/services/di/`; the *only*
directory allowed to import
`Layer`/`Context`/`ManagedRuntime`/`TestClock`)

| Module | Contents |
|---|---|
| `tags.ts` | One `Context.Service` tag per service class provided by
the graph. Type-only imports of service classes ⇒ no runtime import
cycles. Ids `"xum/<Name>"`. Naming: class name minus trailing `Service`
(`MemoryMeta`, `Workspace`, `History`); classes without that suffix or
colliding with an exported name get a `Tag` suffix (`ConfigTag`,
`StreamManagerTag`, `IdleDispatcherTag`). Exports the unions `CoreTags`
and `AppTags`. |
| `effectRunner.ts` | `interface EffectRunner { runSync<A,E>(e:
Effect<A,E,never>): A; runSyncExit; runFork; runPromise; runPromiseExit
}` — a **context-bound, unsupervised** runner whose methods accept only
effects with **no service requirements** (`R = never`; defaulted
references like `Clock` do not appear in `R`). That makes "not a service
locator" type-enforced: a fiber that needs services must take them as
explicit constructor dependencies and, if it must be awaited on
shutdown, fork into `AppFiberScope`. `defaultEffectRunner` = the global
`Effect.runX` (today's exact behavior). `effectRunnerFromContext(ctx)` =
`Effect.run…With(ctx)`. `EffectRunnerTag` + `EffectRunnerLive =
Layer.effect(EffectRunnerTag, Effect.map(Effect.context<never>(),
effectRunnerFromContext))`, placed at the **base** of the graph so the
captured context contains only refs (`Clock`, later `Logger`/`Random`)
plus stores. Fibers forked through it are owned by the worker's own
`Scope` (explicit `start/stop`), **not** by the ManagedRuntime;
`runtime.dispose()` does not interrupt them. Services import only this
file from `di/`. |
| `appFiberScope.ts` | `AppFiberScopeTag: Scope.Closeable`.
`AppFiberScopeLive = Layer.effect(AppFiberScopeTag,
Effect.gen(function*(){ const parent = yield* Effect.scope; return
yield* Scope.fork(parent, "parallel"); }))` — a child of the runtime's
layer scope. Fibers forked into it via `Effect.forkIn(_, appFiberScope)`
are interrupted **and awaited** when the scope closes. This is the
**supervised** seam for I/O-suspended fibers (engine core, later).
`ServiceContainer.dispose()` closes it explicitly and early (§5) so
interrupted fibers can still use their dependencies during finalization;
`runtime.dispose()` later re-closes it idempotently as a backstop. No
production occupant in Phase 11; the seam exists with tests. |
| `appRuntime.ts` | `makeAppRuntime(layer)`:
`ManagedRuntime.make(layer)` + **eager synchronous build**
(`runtime.runSync(Effect.context<R>())`; `assert(runtime.cachedContext
!== undefined)`); a layer body that suspends is a programming error and
throws here — exactly where a throwing constructor throws today, so
every entry point's existing catch/dialog/log path is preserved.
`disposeAppRuntime(runtime, timeoutMs)` and `closeScopeBounded(scope,
timeoutMs)` share one shape: `Effect.uninterruptible` teardown shell
around `Effect.interruptible(target.pipe(Effect.timeout(timeoutMs)))`
where `target` is `runtime.disposeEffect` resp. `Scope.close(scope,
Exit.void)` (never a non-cancellable JS Promise wrapper);
`Effect.catchTag("TimeoutError", …)` + `Effect.catchDefect` →
`log.warn`; run via `Effect.runPromise`; **never rejects**; idempotent
(`Scope.close` is idempotent; `disposeEffect` is guarded by a latch).
Verify the exact rc `Effect.timeout` error type at implementation time
(rc.112: fails with `Cause.TimeoutError`, `_tag: "TimeoutError"`).
Module doc comment = the DI contract (§2.3, §5). |
| `layers/stores.ts` | `StoresLive(stores: ConfigStores)` =
`Layer.mergeAll` of `Layer.succeed` for `ConfigTag`,
`SessionLocatorTag`, `ProvidersConfigStoreTag`, `SecretsStoreTag`,
`FileLeaseManagerTag` (true siblings — no inter-dependencies).
`StoresFromCoreOptionsLive` reproduces the `opts.x ?? new
X(config.rootDir)` defaults of `coreServices.ts:106-112` for the CLI
root. |
| `layers/core.ts` | `CoreOptionsTag` (today's `CoreServicesOptions`
minus stores — carries the *optional* cross-cutting services exactly as
today). **PR 3:** `CoreProjectionLive = Layer.effectContext(...)`
wrapping the existing `createCoreServices` body and returning a
`Context<CoreTags>` (coarse projection, zero behavior change). **PR 4:**
peel into per-service `Layer.effect(Tag, Effect.gen(...))` layers
composed in **explicit dependency stages** (`Layer.provideMerge` between
stages; `Layer.mergeAll` only for true siblings within a stage — every
sibling claim below was checked against the constructor argument lists
in `coreServices.ts` and must be re-checked in the PR): S1 History ·
InitState · Provider · BackgroundProcess · ExtensionMetadata ·
MemoryMeta · TerminalAttention · IdleDispatcher ·
WorkspaceMcpOverrides(default) · `TurnRequestBuilderBindingsTag`
(`Layer.succeed(_, {})`) → S2a SessionUsage · Goal · Memory → S2b
StreamManager (needs SessionUsage) → S3 AIService → S4 Consolidation ·
MCPConfig → S5 MCPServerManager → S6 Workspace → S7 Task → S8
TurnManager → `CoreWiringLive` (`Layer.effectDiscard`, **`Effect.sync`
only — no `acquireRelease`**, replays `coreServices.ts:137-166, 209-210,
258-270, 288-325, 349-352, 360-367` in order). |
| `layers/desktop.ts` | `CrossCuttingLive` (policy, telemetry,
experiments, backup, sessionTiming, analytics, devTools,
workspaceMcpOverrides, browserBridgeTokenManager),
`CoreOptionsFromDesktopLive` (derives `CoreOptionsTag` from those tags +
`extensionMetadataPath`), then **group layers** (`Layer.effectContext`
returning a `Context` of several tags, constructed in today's order):
`BrowserLive`, `DesktopBridgeLive`, `OauthLive`, `WorkersLive`
(idleCompaction, heartbeat, agentStatus, timeline, refine),
`TerminalEditorLive`, `MiscDesktopLive`; staged with `provideMerge`
where one group needs another. `DesktopWiringLive` (`Effect.sync` only)
= setters +
`aiService.on/workspaceService.on/memoryConsolidationService.on` wiring
+ global registrations. |
| `layers/app.ts` | `AppLive(stores) = DesktopLive ▹ CoreLive ▹
CoreOptionsFromDesktopLive ▹ CrossCuttingLive ▹ AppFiberScopeLive ▹
EffectRunnerLive ▹ StoresLive(stores)` — read `X ▹ Y` as "X is *provided
with* Y, and both stay exposed", i.e.
**`X.pipe(Layer.provideMerge(Y))`** (rc.112 signature:
`provideMerge(that: provider)(self: consumer)`; the *right-hand* operand
is the dependency). Every `▹` keeps all tags visible in the final
`Context<AppTags>`. |
| `testEffectRunner.ts` (test helper, sibling of
`testHistoryService.ts`) | `makeTestEffectRunner()` → `{ runner,
adjust(duration), setTime(ms), dispose }` over one memoised
`ManagedRuntime.make(EffectRunnerLive.pipe(Layer.provideMerge(TestClock.layer())))`
(the TestClock is the *provider*; the runner captures it), so the worker
under test and `TestClock.adjust` share one `TestClock`. |

### 2.2 Composition roots after Phase 11

```mermaid
flowchart TB
  Stores["StoresLive(stores)<br/>Config · SessionLocator · ProvidersConfigStore · SecretsStore · FileLeaseManager"]
  Runner["EffectRunnerLive (unsupervised, ref-bound)<br/>+ AppFiberScopeLive (supervised, closed on dispose)"]
  Cross["CrossCuttingLive (desktop only)<br/>Policy · Telemetry · Experiments · Analytics · SessionTiming · DevTools · WorkspaceMcpOverrides · Backup"]
  Opts["CoreOptionsTag<br/>desktop: derived from CrossCutting · CLI: Layer.succeed(opts)"]
  Core["CoreLive<br/>PR 3: coarse CoreProjectionLive → PR 4: stages S1…S8 + CoreWiringLive"]
  Desk["DesktopLive — group Layers<br/>Browser · DesktopBridge · OAuth · Workers · TerminalEditor · Misc → DesktopWiringLive"]
  RT["AppRuntime = ManagedRuntime.make(AppLive)<br/>eager sync build · Context<AppTags> = oRPC effect/context · dispose() last"]
  Stores --> Runner --> Cross --> Opts --> Core --> Desk --> RT
  CLI["CLI root (xum run / xum workflow)<br/>createCoreServices(opts) = makeAppRuntime(CoreLive ▹ StoresFromCoreOptionsLive ▹ AppFiberScopeLive ▹ EffectRunnerLive ▹ succeed(CoreOptionsTag, opts))"]
  Core -.same Layer definitions.-> CLI
```

`ServiceContainer` keeps its public fields and the synchronous `new
ServiceContainer(stores)`: the constructor calls
`makeAppRuntime(AppLive(stores))`, stores `this.serviceContext =
runtime.runSync(Effect.context<AppTags>())`, and assigns fields via
`Context.get(this.serviceContext, Tag)`. `toORPCContext()` returns the
same plain fields plus `"effect/context": this.serviceContext`.
`initialize()` is untouched. `dispose()` follows §5.

`createCoreServices(opts)` keeps its signature and return shape plus
`runtime` and `appFiberScope` fields; `cli/run.ts:1574-1580` and
`cli/workflow.ts:275-320` cleanup lists gain
`closeScopeBounded(appFiberScope)` before `session.dispose()` and
`disposeAppRuntime(runtime)` as the final step (PR 3).

**Staged composition skeleton (PR 4 shape; direction matters):**

```ts
// Each stage depends only on stages defined above it. `provideMerge` keeps both sides exposed.
const S1 = Layer.mergeAll(HistoryLive, InitStateLive, ProviderLive, /* … true siblings only */);
const S2a = Layer.mergeAll(SessionUsageLive, GoalLive, MemoryLive).pipe(Layer.provideMerge(S1));
const S2b = StreamManagerLive.pipe(Layer.provideMerge(S2a));          // StreamManager needs SessionUsage
const S3 = AIServiceLive.pipe(Layer.provideMerge(S2b));
// … S4 … S8 likewise …
export const CoreLive = CoreWiringLive.pipe(Layer.provideMerge(S8));  // wiring runs after every service exists
```

**oRPC typing.** `OrpcEffectServices` (in `effectContext.ts`) becomes
`AppTags`, so `ORPCContext["effect/context"]: Context<AppTags>` is
satisfied by the runtime context in production. `buildOrpcEffectContext`
stays as the narrow test helper it already is (its only caller,
`effectBridge.test.ts:24-30`, deliberately builds a partial context and
casts it via `unknown`); no production caller remains after PR 1.

### 2.3 Invariants (the "DI contract"; enforced by tests and the
`appRuntime.ts` doc comment)

| # | Invariant | Constraint served |
|---|---|---|
| I1 | **Phase 11 compatibility contract, not permanent law:** layer
bodies are synchronous (`Layer.succeed`/`Layer.sync`/`Layer.effect` over
sync effects; `acquireRelease` with a sync acquire is fine).
`makeAppRuntime` asserts the eager build completed. Future async
resource acquisition belongs in `initialize()`/startup effects or an
explicit async factory root (`ServiceContainer.create()`), never
silently inside a layer. | #2 sync-start, #5 startup parity |
| I2 | Services never hold the `ManagedRuntime`. Workers hold an
`EffectRunner` (default `defaultEffectRunner`); `EffectRunner.runX` ≡
`Effect.run…With(ctx)` — same sync-start semantics as `Effect.runX`, and
still valid after `runtime.dispose()`, so late callbacks cannot hit
"ManagedRuntime disposed". Supervision, when needed, is explicit via
`AppFiberScope`. | #2, #3 |
| I3 | Per-call pipelines (`Effect.runPromise(this.effects…)` facades)
and the `memoryConsolidationService` funnels are untouched. **Audit
item:** no DI lookup, runner call, or `await` may be inserted before
`inFlight.set` / `harvestInFlight.set`. Only lifecycle forks in workers
move to `this.runner.runX`. | #1, #2 |
| I4 | Constructors, facades, private methods, module exports unchanged;
new constructor parameters are optional, trailing, defaulting to
`defaultEffectRunner`. | #1, #6 |
| I5 | Teardown order stays explicit in `dispose()`/`shutdown()`. Layer
bodies and wiring layers register **no finalizers** in Phase 11
(`Effect.sync` only), so `runtime.dispose()` reorders nothing. The one
supervised resource (`AppFiberScope`) is closed explicitly at a fixed
position in `dispose()` (§5). | #3 |
| I6 | Wiring layers replay today's setter/listener order; a constructor
may touch only its *declared* dependencies (built earlier by staging).
Per-PR audit: grep each moved constructor for calls on setter-provided
collaborators → forbidden. Dependency order is expressed only with
`provide`/`provideMerge` stages; never rely on `mergeAll` sibling order.
| #6 |
| I7 | No persisted-data changes; DI is in-process only. | #4 |
| I8 | Every process root builds from the same Layer definitions
(`CoreLive` shared by App and CLI). Unit harnesses
(`createTestHistoryService`, `createTestToolConfig`,
`createAgentSessionHarness`, …) intentionally bypass Layers. | #7 |

### 2.4 Decisions and alternatives (product-LoC deltas)

<details>
<summary>D1 — Granularity: coarse core first (PR 3), per-service core
stages behind a decision gate (PR 4), group layers for the desktop tail
(PR 5)</summary>

Honest framing: the three unlocks (engine-core async scope, TestClock,
app-lifetime scope) are delivered by `AppRuntime` + `EffectRunner` +
`AppFiberScope` and **do not require per-service layers**. Per-service
core layers are *migration leverage*: typed requirement sets for the
engine-core work, per-service swap in integration tests, explicit
dependency stages instead of implicit ordering.

- **(A) Per-service everywhere** (~70 layers): +~900/−~700. Desktop tail
has hand-tuned teardown that must not become finalizers, so per-service
there buys uniformity only. Rejected.
- **(B) Recommended:** PR 3 coarse `CoreProjectionLive` (+~120/−~10)
delivers the shared root and runtime ownership; PR 4 peels the core into
staged per-service layers (+~330/−~290) **only if** PR 3's
typecheck/startup budgets hold (gate in §3); desktop tail as ~6 group
layers (+~170/−~150). Tags for all services either way (~3 LoC each).
- **(C) Coarse only:** stop after PR 3 + desktop projection (~+200
total). Cheapest; the engine-core phase would then redo dependency
declarations. Remains the fallback if PR 4's gate fails.
</details>

<details>
<summary>D2 — Async init stays an explicit `initialize()`; Layers
construct only</summary>

Folding `initialize()` into layer construction would make the build
asynchronous (breaks I1), change failure semantics (today: fail-fast →
dialog/log), and move the six-step order into memoised builds. Deferred;
a later phase can turn `initialize()` into
`runtime.runPromise(startupEffect)` with per-step `Effect.timeout`.
</details>

<details>
<summary>D3 — Optional cross-cutting services stay optional via
`CoreOptionsTag`, not `Effect.serviceOption`</summary>

Core layer bodies read `opts.policyService` etc. exactly as today, so
CLI (absent) vs desktop (present) behavior is unchanged and no service
gains a new `undefined` branch.
</details>

<details>
<summary>D4 — Two seams instead of one: `EffectRunner` (unsupervised,
clock-bound) + `AppFiberScope` (supervised)</summary>

A single "runtime handle" conflates two needs. Workers need *which
clock* (TestClock) and must keep sync `stop()`; the engine core needs
*who awaits me on shutdown*. Explicit `Clock` injection per worker was
rejected (a `provideService(Clock.Clock, …)` at every fork site, and it
does not extend to other refs).
</details>

<details>
<summary>D5 — oRPC: `effect/context` = the runtime's `Context`;
`handlerGen` unchanged</summary>

`handlerGen` already `Effect.provide`s the context per request;
providing ~70 entries instead of one is one Map merge per request. The
existing `echoAsync`/`echoEffect` probes record the delta as a
**diagnostic** in the PR body (no stable benchmark harness exists to
make it a hard gate). `effect/wrap` not needed.
</details>

## 3. Phasing — six stacked PRs

Every PR: `make static-check`; gate suites below; existing tests
unchanged (PR 6 is the only PR that edits tests, and only to replace
real-timer probes). Before `@codex review`, run the **house pre-review
audits**:

1. **Interruption posture** — list every new/moved fiber fork; state
what interrupts it and when (unsupervised via `EffectRunner` + worker
scope, or supervised via `AppFiberScope`).
2. **Uninterruptible teardown** — teardown effects are
`Effect.uninterruptible` end-to-end; bounded waits inside use
`Effect.interruptible(Effect.timeout(...))` (house shape from #4038).
3. **No defect escapes** — `disposeAppRuntime`/`closeScopeBounded` and
every Promise facade fold defects; `makeAppRuntime` is the one place
allowed to throw (constructor semantics).
4. **Spy-seam check** — `rg 'spyOn\('
src/node/services/<touched>.test.ts tests/` per touched class;
constructor arity and private-method Promise signatures unchanged
(typecheck of tests proves it).
5. **Sync-start check** — a fork through `EffectRunner` runs to its
first `sleep` before `runFork` returns (mirrors
`heartbeatService.ts:199-202`).
6. **Constructor side-effect audit (I6)** for every constructor moved
into a Layer in that PR.
7. **Zero-suspension audit (I3)** whenever `memoryConsolidationService`
is in the diff.

### PR 1 — Skeleton: AppRuntime + Stores/MemoryMeta layers +
runtime-backed `effect/context` + dispose hook (+~150 LoC)

**Scope**
- `di/tags.ts` (`ConfigTag`, `SessionLocatorTag`,
`ProvidersConfigStoreTag`, `SecretsStoreTag`, `FileLeaseManagerTag`,
`MemoryMeta` moved from `orpc/effectContext.ts`, which re-exports it;
`AppTags` union).
- `di/layers/stores.ts` (`StoresLive`), `di/layers/core.ts` with
`MemoryMetaLive = Layer.effect(MemoryMeta, Effect.map(ConfigTag, c =>
new MemoryMetaService(c.rootDir)))`, `di/layers/app.ts`
(`AppLive(stores) = MemoryMetaLive ▹ StoresLive`).
- `di/appRuntime.ts` (`makeAppRuntime`, `disposeAppRuntime`);
`APP_RUNTIME_DISPOSE_TIMEOUT_MS` in `src/constants/`.
- `coreServices.ts`: `CoreServicesOptions.memoryMetaService?`
(precedent: `workspaceMcpOverridesService?`).
- `serviceContainer.ts`: build runtime first, pass `Context.get(ctx,
MemoryMeta)` to `createCoreServices`, `public readonly runtime`,
`toORPCContext()["effect/context"] = this.serviceContext`, `dispose()`
appends `disposeAppRuntime` behind a `disposed` latch; new
`log.debug("[startup] AppRuntime built", { ms })`.
- `orpc/effectContext.ts`: `OrpcEffectServices = AppTags`;
`buildOrpcEffectContext` retyped/test-helper doc.
- `headlessEnvironment.dispose` calls `await services.dispose()` before
removing the temp dir (the bench harness currently leaks the container;
runtime ownership starts here).

**Acceptance**
- `di/appRuntime.test.ts`: (a) sync build sets `cachedContext`; (b) a
layer with an async body makes `makeAppRuntime` **throw synchronously**
(I1 enforced); (c) probe layers' finalizers run in reverse order on
dispose; (d) dispose is idempotent and bounded (hung finalizer → `warn`,
resolves at the timeout); (e) `runtime.runFork` after the eager build
starts synchronously.
- `serviceContainer.test.ts`:
`Context.get(toORPCContext()["effect/context"], MemoryMeta) ===
services.memoryMetaService`; `dispose()` closes the runtime; `dispose();
shutdown()` (tests/ipc order) is clean; a throwing layer surfaces as a
synchronous throw from `new ServiceContainer(stores)` (same shape as
today's constructor throw → existing entry-point catch paths).
- `effectBridge.test.ts`, `memoryMeta*.test.ts` unchanged and green;
echo-probe overhead recorded in the PR body.
- Gate: `bun test src/node/services/di
src/node/services/serviceContainer.test.ts src/node/orpc
src/node/services/memoryMeta*` · `make test-integration` · `make
static-check`.

**Rollback:** `git revert`; classes untouched.

### PR 2 — Runtime seams: `EffectRunner` + `AppFiberScope`; TestClock on
idleCompaction/heartbeat/retryManager (+~140 LoC)

**Scope**
- `di/effectRunner.ts`, `di/appFiberScope.ts`; `AppLive` gains
`AppFiberScopeLive ▹ EffectRunnerLive` at the base; `ServiceContainer`
exposes `appFiberScope` (used only by `dispose()` in Phase 11) and
closes it per §5.
- `IdleCompactionService`, `HeartbeatService`, `RetryManager`: trailing
optional `runner: EffectRunner = defaultEffectRunner`; every lifecycle
`Effect.runSync/runFork` in `start/stop/schedule/cancel` becomes
`this.runner.runX`. Deadline math (`Date.now()`/injected `now`)
unchanged. `ServiceContainer` passes `Context.get(ctx, EffectRunnerTag)`
to the two workers; `RetryManager` keeps the default until PR 5 (so
`streamManager.ts` is untouched here).
- `di/testEffectRunner.ts` helper.

**Acceptance**
- New TestClock tests (existing real-timer tests untouched — they
exercise the `defaultEffectRunner` path, which is production behavior
wherever no runner is injected): heartbeat `STARTUP_DELAY_MS` → first
tick after `adjust`, one tick per `CHECK_INTERVAL_MS`, no ticks after
`stop()`; idleCompaction initial delay + cadence; retryManager fires
exactly at `delayMs`, `cancel()` before `adjust` never fires.
- Pin runtime facts: `runner.runSync(Scope.close(scope, Exit.void))`
completes synchronously for a fiber suspended on a TestClock sleep;
`runFork` through the runner reaches its first sleep synchronously;
`Effect.context<never>()` inside `EffectRunnerLive` sees the upstream
`TestClock` (else the helper provides `Clock.Clock` explicitly — same
seam, one line).
- `AppFiberScope` contract tests: (i) an **I/O-suspended** fiber
(interruptible `Effect.async` that never resolves, with a cancel path)
forked with `Effect.forkIn(_, appFiberScope)` is interrupted **and
awaited** by `closeScopeBounded(appFiberScope)` — and this happens
*before* the explicit teardown steps in `dispose()` (assert ordering
against a spy on `desktopBridgeServer.stop`); (ii) a fiber forked via
`EffectRunner` is *not* interrupted by either close (documents the
asymmetry); (iii) `disposeAppRuntime` afterwards idempotently re-closes
the already-closed child scope (no error, no second finalizer run).
- If `TestClock.adjust` leaves continuations pending, the helper adds
`Effect.yieldNow`/`Fiber.await` — decided by tests.
- Gate: `heartbeatService.test.ts`, `idleCompactionService.test.ts`,
`retryManager.test.ts`, `serviceContainer.test.ts`, `di/*`, tests/ipc.

**Rollback:** revert restores defaults; no call site depends on the new
params.

### PR 3 — Shared core root: coarse `CoreProjectionLive` +
`createCoreServices` facade + CLI runtime disposal (+~120 / −~10)

**Scope**
- Tags for the remaining 19 core services; `CoreOptionsTag`;
`StoresFromCoreOptionsLive`.
- `CoreProjectionLive = Layer.effectContext(Effect.gen(function*(){
const opts = yield* CoreOptionsTag; const stores = yield* …; const core
= buildCoreGraph({ ...opts, ...stores }); return Context.make(History,
core.historyService).pipe(Context.add(...)) }))` where `buildCoreGraph`
is today's `createCoreServices` body, unchanged, renamed.
- `createCoreServices(opts)` = `makeAppRuntime(CoreProjectionLive ▹
StoresFromCoreOptionsLive ▹ AppFiberScopeLive ▹ EffectRunnerLive ▹
Layer.succeed(CoreOptionsTag, opts))`, returns today's `CoreServices`
object read from the context plus `runtime` and `appFiberScope`.
`cli/run.ts` and `cli/workflow.ts` cleanup lists append
`closeScopeBounded(appFiberScope)` **before** `session.dispose()` and
`disposeAppRuntime(runtime)` **after**
`backgroundProcessManager.terminateAll()`.
- `ServiceContainer` stops calling `createCoreServices`; `AppLive =
CoreProjectionLive ▹ CoreOptionsFromDesktopLive ▹ CrossCuttingLive ▹ …`
(cross-cutting services move into `CrossCuttingLive` now because core
options derive from them). Desktop constructions otherwise stay in the
constructor.

**Acceptance**
- Identity test: every `CoreServices` field `===` `Context.get(ctx,
Tag)`; `serviceContainer.test.ts` unchanged and green.
- **Decision gate for PR 4** recorded in the PR body: `make typecheck`
wall time, `[startup] AppRuntime built` ms and `initialize` totals vs
`origin/main` baseline from the sandbox (§7). Proceed to PR 4 only if
typecheck regresses < 10 % and startup within noise; otherwise stop at
(C).
- Gate: `bun test src/node/services`, `src/cli/*.test.ts`
(run/workflow/server/cli), tests/ipc, `make static-check`.

**Rollback:** revert restores the imperative call; PR 1/2 unaffected.

### PR 4 — Peel the core into staged per-service Layers +
`CoreWiringLive` (+~330 / −~290 ⇒ net ≈ +40; split 4a/4b if > ~600 diff
lines)

**Scope**
- Stages S1, S2a, S2b, S3…S8 (§2.1 + skeleton in §2.2) as `Layer.effect`
adapters with today's argument lists; `CoreWiringLive` (`Effect.sync`
only) replays the wiring lines in order; `CoreLive =
CoreWiringLive.pipe(Layer.provideMerge(S8))` replaces
`CoreProjectionLive`; `buildCoreGraph` deleted.
- Before writing any stage: re-derive the DAG from the constructor
argument lists (the plan's stage table was checked once; `StreamManager
→ SessionUsage` is the kind of edge that turns "siblings" into a stage
split) and record it in the PR body.
- 4a (S1–S3: leaves through `AIService`) / 4b (S4–S8 + wiring) if needed
— 4a alone is mergeable because the remaining services are built by a
shrunken projection layer that reads S1–S3 from the context.

**Acceptance**
- Wiring assertions that are behavioral (a missing wiring line fails
them): `turnRequestBuilderBindings` fully populated; goal continuation
consumer registered on `idleDispatcher`; `streamManager` MCP manager
set; registration probe installed on `extensionMetadata`.
- I6 audit table for all 19 constructors in the PR body;
missing-provider = compile error (R must be `never` at `makeAppRuntime`)
demonstrated by a type-level test (`// @ts-expect-error`).
- Gate: as PR 3 plus `streamManager*.test.ts`, `aiService.test.ts`,
`workspaceService*.test.ts`.

**Rollback:** revert to PR 3's projection.

### PR 5 — `DesktopLive` group layers + `DesktopWiringLive`; thin
`ServiceContainer`; `StreamManager` runner param (+~170 / −~150 ⇒ net ≈
+20)

**Scope**
- Tags for the 45 desktop services; six group layers
(`Layer.effectContext`, today's construction order inside each;
`provideMerge` between groups that depend on each other);
`DesktopWiringLive` (`Effect.sync` only) = `serviceContainer.ts:209,
263-265, 271, 288-290, 334-340, 348, 365, 375, 381-382, 434, 438-471,
474-574` in order.
- `ServiceContainer` constructor = `makeAppRuntime(AppLive(stores))` +
field assignment from the context. `toORPCContext()` unchanged in shape.
- `StreamManager`: optional trailing `runner: EffectRunner`;
`schedulePartialWrite` fork (`streamManager.ts:1141`) and `RetryManager`
construction use it; `Scope.close` stays `Effect.runFork` (existing
async-close precedent). `WorkersLive` receives `EffectRunnerTag`.

**Acceptance**
- All four existing `serviceContainer.test.ts` assertions unchanged; new
identity test over `toORPCContext()` fields vs tags;
`dispose()`/`shutdown()` call order asserted via spies on the *public*
methods already spied today.
- I6 audit for the 45 constructors.
- Gate: tests/ipc + tests/ui (`make test-integration`),
`src/cli/server.test.ts`, `src/cli/cli.test.ts`,
`streamManager*.test.ts`, `aiService.test.ts`.

### PR 6 — TestClock adoption sweep + shutdown hardening + contract docs
(+~20 LoC product; tests edited)

**Scope**
- Replace real-sleep cadence probes with `makeTestEffectRunner()` in
`heartbeatService.test.ts`, `idleCompactionService.test.ts`,
`retryManager.test.ts`, and the partial-write debounce cases of
`streamManager.test.ts`; keep **one real-timer smoke test per worker**
(guards the `defaultEffectRunner` path).
- `cli/server.ts`: `[shutdown]` log lines per step incl. `AppRuntime
disposed {ms}`; confirm the whole `dispose()` fits the existing 5 s
force-exit budget.
- Finalize the contract doc comment in `di/appRuntime.ts` (I1–I8, §5).

**Acceptance:** converted suites have zero `setTimeout`-based cadence
waits (grep in PR body), same assertions; `make test-integration` green;
sandbox startup/shutdown evidence (§7).

## 4. TestClock story

- **Mechanism.** `Effect.sleep`, `Schedule.fixed`, `Effect.timeout`,
`Clock.currentTimeMillis` read the `Clock` reference from the running
fiber's context. Workers that fork through an `EffectRunner` built under
`TestClock.layer()` run on the test clock; `await testRunner.adjust("2
minutes")` advances it. `Date.now()`, `setTimeout`, `setInterval` are
unaffected — heartbeat deadline math via injected `now`,
`AgentStatusService`'s ref'd `setInterval`, and
`backgroundProcessManager` stay on real timers/injected timestamps.
- **Benefit now:** `heartbeatService.test.ts` (6),
`idleCompactionService.test.ts` (2), `retryManager.test.ts` (3
`setSystemTime` → `adjust`; `Date.now`-based `retryAt` may move to
`Clock.currentTimeMillis` only if a test needs both clocks aligned),
`streamManager.test.ts` debounce cases (7).
- **Deferred:** `streamBridge.test.ts` ticker (11) — needs a
context/runner parameter on `subscriptionIterable`; OAuth device-flow
polling and `oauthFlowManager.test.ts` (25) — non-goal.
- **Stays real:** child-process/PTY/WASM/fs-lock waits
(`backgroundProcessManager` 72, `quickjsRuntime` 26, lock sleeps in
`workspaceService`/`taskService`), end-to-end suites (tests/ipc, e2e).
- **Pinned in PR 2, not assumed:** `adjust` runs due sleeps and their
synchronous continuations before resolving (or the helper yields until
they do); `Schedule.fixed` anchoring under `TestClock` matches the
wall-clock expectations in `heartbeatService.ts:149-155`; sync
`Scope.close` of a TestClock-suspended fiber completes synchronously.

## 5. Shutdown protocol

1. **Trigger points unchanged:** `main.ts` `before-quit` (preventDefault
→ `dispose()` raced with 5 s → `app.quit()`; update-install path
fire-and-forget), the second `before-quit` listener's `shutdown()`
(unchanged, concurrent), `cli/server.ts` SIGINT/SIGTERM (5 s force
exit), ACP `close()`, tests/ipc (`dispose()` then `shutdown()`),
headless bench (`dispose()` from PR 1).
2. **`ServiceContainer.dispose()` order:**
1. `backgroundProcessManager.beginShutdown()` — unchanged, first (latch
protecting persisted monitor records).
2. **`closeScopeBounded(appFiberScope,
APP_FIBER_SCOPE_CLOSE_TIMEOUT_MS)`** — interrupts and awaits supervised
fibers *while every dependency they might touch during finalization is
still alive*. No occupants in Phase 11; the position is fixed now so the
engine-core phase does not have to re-derive it.
3. The existing explicit sequence verbatim (`desktopBridgeServer.stop()`
… `terminateAll()` … `timelineService.flush()`).
4. **`disposeAppRuntime(runtime, APP_RUNTIME_DISPOSE_TIMEOUT_MS)`** —
closes the runtime scope (interrupts any fiber started via
`runtime.runX` — none long-lived in Phase 11; runs layer finalizers —
none in Phase 11 by I5). Hung → `warn` at the timeout; never rejects.
Budget: 2 s + 2 s inner bounds inside the callers' 5 s outer budgets;
the outer race in `main.ts` remains the last line of defense.
**Rule for future occupants:** anything forked into `AppFiberScope` must
tolerate interruption at any suspension point and must not depend on
resources torn down in step 1; anything that needs a Layer finalizer
must first prove reverse-construction order is compatible with steps 2–3
(I5).
3. **Latches:** `disposed` makes `dispose()` idempotent (two
`before-quit` listeners, tests/ipc dispose+shutdown). `shutdown()` never
touches the runtime or `AppFiberScope`.
4. **Late callers:** `EffectRunner` handles keep working after runtime
dispose (I2), so a stray `tick()`/`scheduleRetry()` after quit cannot
defect. The `ManagedRuntime` is referenced only by `ServiceContainer`
and the `createCoreServices` return value.
5. **Worker `stop()` stays synchronous** (`runner.runSync(Scope.close)`)
because their fibers suspend only on the clock. The engine core will
fork into `AppFiberScope` (step 2.2 awaits it) — the reason both seams
exist now.
6. **Crash paths:** unchanged — `uncaughtException`/SIGKILL run no
finalizers. Finalizers are best-effort; durable state must remain
crash-safe without them (AGENTS.md self-healing rule). Nothing in Phase
11 makes a finalizer the sole guardian of durable state.

## 6. Risk register

| # | Risk | L/I | Mitigation |
|---|---|---|---|
| R1 | A layer body suspends → `runSync` throws at startup | M/H | I1
assert + PR 1 test (b); doc comment; review checklist; entry-point catch
paths verified in PR 1 |
| R2 | Construction-order side effects differ under staged builds | L/H
| I6 audit per moved constructor; explicit `provideMerge` stages; wiring
layers replay today's order; tests/ipc as behavioral gate |
| R3 | Double teardown (`shutdown()` ∥ `dispose()`; dispose+shutdown in
tests) | M/M | `disposed` latch; runtime/AppFiberScope closed only in
`dispose()`; PR 1 test |
| R4 | Late `runtime.runX` after dispose → defect | M/M | I2: services
hold `EffectRunner`, never the ManagedRuntime |
| R5 | TestClock semantics differ from assumptions | M/L | PR 2 pins
them before any suite converts; per-suite fallback to real timers |
| R6 | effect v4 RC churn (`Context`→`ServiceMap`, Layer renames) | M/M
| All `Layer/Context/ManagedRuntime/TestClock` imports confined to
`di/`; exact pin |
| R7 | Startup latency regression (splash) | L/M | `AppRuntime built` ms
+ `initialize` totals vs baseline in sandbox; PR 3 gate |
| R8 | Typecheck slowdown from large requirement unions | L/L | PR 3
gate records `make typecheck` wall time; fallback (C) |
| R9 | Per-request `Effect.provide` of a ~70-entry Context | L/L |
echo-probe diagnostic in PR 1/5 bodies |
| R10 | Spy seams / direct-construction tests break | L/H | I4; optional
trailing params; audit 4; typecheck of tests |
| R11 | CLI roots forget to dispose runtime/scope | M/L | PR 3 wires
both cleanups; `src/cli/*.test.ts` assert the cleanup steps exist |
| R12 | Someone forks long-lived I/O work via `EffectRunner` expecting
dispose to await it | M/M | Doc on `EffectRunner` ("unsupervised"); PR 2
asymmetry test; review audit 1 |

**Rollback:** PRs are stacked; revert in reverse order (6→1). Service
classes are never modified except for optional trailing params, so any
revert restores the previous composition root wholesale with no data or
API implications.

## 7. Dogfooding (per PR; evidence attached to the PR body)

**Environment (headless Coder host, no `DISPLAY`):**
```bash
XUM_LOG_LEVEL=debug DEV_SERVER_SANDBOX_ARGS="--clean-projects" make dev-server-sandbox   # background bash task; prints URL + XUM_ROOT
```
- **Startup correctness:** `<XUM_ROOT>/logs/*.log` shows, in order:
`Loading services...`, `[startup] AppRuntime built {ms}`, `[startup]
ServiceContainer.initialize starting`, six step durations, `[startup]
ServiceContainer.initialize completed {totalMs, stepDurationsMs}`. Paste
baseline (`origin/main`) vs branch numbers.
- **Startup-never-crash parity (once, locally, not committed):** inject
a throwing scratch layer → `xum server` exits non-zero with the existing
logged error and **no** unhandled-rejection trace; for desktop, confirm
by code path (`loadServices()` rejects → `main.ts:1255` dialog) and via
`src/cli/server.test.ts`/ACP tests.
- **UI smoke (agent-browser):** `open <url>` → `snapshot -i` → add a
scratch git repo as a project → create a workspace → send one message →
`screenshot` the loaded app and the response; `attach_file` both.
**Video:** start `agent-browser record` before the flow and stop it with
a hard timeout (`timeout 30 agent-browser record stop`); if stopping
hangs (known), attach the truncated WebM plus the screenshots and say
so.
- **oRPC Effect path:** pin/unpin a memory entry (rides `handlerGen` +
runtime `effect/context`); screenshot before/after; grep logs for
`ManagedRuntime disposed`/defect lines (expect none).
- **Graceful quit:** record the terminal with `script -q
/tmp/<workspace>-shutdown.log` (or `agent-tty` if present), `kill -TERM
<pid>` → expect `[shutdown]` lines, `AppRuntime disposed {ms}`, exit 0,
no force-exit message; attach the typescript. Exercise the timeout
branch once with a scratch hung finalizer → `warn` + timely exit.
- **Electron (best effort):** with `Xvfb`, `make dev` + agent-browser
via CDP (electron skill): screenshot splash → main window, quit via
menu, confirm exit < 5 s; otherwise state that the Electron path is
covered by `tests/e2e` in CI and the shared `dispose()` path exercised
by `server.ts`.

**Gate suites per PR** (plus `make static-check` always):

| PR | Must pass |
|---|---|
| 1 | `src/node/services/di/*`, `serviceContainer.test.ts`,
`src/node/orpc/*`, `memoryMeta*`, `make test-integration` |
| 2 | + `heartbeatService.test.ts`, `idleCompactionService.test.ts`,
`retryManager.test.ts` |
| 3 | + `bun test src/node/services`, `src/cli/*.test.ts`; record PR 4
gate numbers |
| 4 | + `streamManager*.test.ts`, `aiService.test.ts`,
`workspaceService*.test.ts` |
| 5 | + tests/ui via `make test-integration`, `src/cli/server.test.ts`,
`src/cli/cli.test.ts` |
| 6 | converted suites + full `make test-integration` + sandbox
startup/shutdown evidence |

## 8. Non-goals (explicit)

- streamManager ENGINE CORE conversion (first `AppFiberScope` occupant;
separate phase).
- `Schema` at persistence boundaries; OAuth refresh/device-flow workers;
`AgentStatusService` `setInterval` → Effect.
- `initialize()` as a Layer/startup effect (D2); per-service optional
tags (D3); `streamBridge` on the runtime; layer finalizers for existing
`dispose()` steps.
- Any change to persisted data, IPC wire shapes, or oRPC handler bodies
beyond the `effect/context` source.

## 9. Assumptions stated

- `Effect.context<never>()` inside `EffectRunnerLive` returns the
enclosing build context including an upstream `TestClock` entry (PR 2
test; fallback: provide `Clock.Clock` explicitly in the helper).
- `Scope.fork(parent)` inside a `Layer.effect` body yields a child
closed by the runtime's layer scope on `dispose()` (PR 2 `AppFiberScope`
test).
- Layer bodies never need to observe sibling construction order; all
ordering that matters is expressed as `provide`/`provideMerge` stages or
wiring-layer statement order.
- `EffectRunner`'s `R = never` constraint is sufficient for every
lifecycle fork in the three Phase 11 workers and
`StreamManager.schedulePartialWrite` (they only use
`Effect.sleep`/`Schedule`/`Effect.sync`/`Effect.tryPromise` — no service
tags). Verified by typecheck in PR 2/5.
- The desktop tail's teardown remains explicit unless a later RFC proves
reverse-construction order compatible; this plan does not attempt it.

</details>

---

_Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking:
`xhigh` • Cost: `$45.57`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=45.57 -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 6, 2026
…Effect (coder#4030)

## Summary

Phase 2c — the final Phase 2 slice — of the progressive Effect
migration: converts the async model-creation internals of
`src/node/services/providerModelFactory.ts` to Effect. The `createModel`
/ `resolveAndCreateModel` pipelines are now `Effect.gen` programs
composing via `yield*`, behind thin `Effect.runPromise` Promise facades.
Public API and observable behavior are preserved exactly;
`providerModelFactory.test.ts` passes **unchanged** (128/128).

## Background

Follows the house pattern proven in coder#4022 (spike), coder#4025 (memory), coder#4027
(retryManager + muxGatewayOauthService), and coder#4028 (providerService):
Effect.gen internals, typed failure tags only where callers genuinely
branch, Promise facades keeping pre-Effect callers and tests
byte-identical.

## Implementation

**Converted pipelines** (3):

- `_createModelCore` → `createModelCoreEffect`: the ~1,200-line
provider-dispatch pipeline is one `Effect.gen` program. Its 13 genuinely
async steps (dynamic `PROVIDER_REGISTRY.*()` / `providerDef.import()`
SDK module loads) become `yield* Effect.promise(...)`. The old
whole-pipeline `try/catch` is a single `Effect.catchDefect` fold
producing the identical `{ type: "unknown", raw: "Failed to create
model: ..." }` wire error — defects carry the raw thrown value, so
`getErrorMessage` sees exactly what the old catch block received, for
both synchronous throws and rejected imports (probe-verified).
- `createModel` → facade + `createModelEffect` (core + DevTools
middleware wrap).
- `resolveAndCreateModel` → facade + `resolveAndCreateModelEffect`; its
`await this.createModel(...)` becomes `yield*
self.createModelEffect(...)` — the composition win: resolve+create is
one fiber with no intermediate Promise hop. The former ~50-line inline
result type is extracted to `ResolveAndCreateModelResult` (structurally
identical) so facade and Effect method share it.

**Error taxonomy: zero tags.** A full callsite audit
(turnRequestBuilder, aiService, workspaceTitle/StatusGenerator,
branchSummary, advisor, debug CLI, tests) shows every caller branches on
the `Result<_, SendMessageError>` wire union (`success` / `error.type`),
never on thrown error identity. The wire union stays in the success
channel, matching coder#4028's "tags only where callers branch" rule — here
that count is zero.

**Deliberately NOT converted** (documented in the class doc so reviewers
don't flag the omission):

- Sync read/plumbing paths (`resolveEffectiveModelString`,
`resolveGatewayModelString`, `resolveModelRoute`,
`resolveProviderCredentials` — all synchronous in this codebase): no
async work → fiber overhead without composition win.
- Per-request fetch wrappers and doStream/doGenerate wrappers
(Codex/Coder OAuth `getValidAuth()` token refresh, mux-gateway
auto-logout, Copilot billing classification, gateway usage
normalization): AI SDK-owned async callbacks executed per network
request after model creation — converting them would embed a
`runPromise` boundary per request with no error-typing win.
- `preloadAISDKProviders`: a single `Promise.all` of module imports
(test setup only).
- `streamManager.ts` / OAuthFlowManager: deferred per phase scope.

**Security posture unchanged:** the defect fold reuses `getErrorMessage`
verbatim — no new error wrapping that could capture `configWithCreds`,
headers, or key material into error strings; no logging added.

One TypeScript nuance: generator bodies lose the contextual typing the
old `async` return annotations provided, so wire-error literals (`Err({
type: "policy_denied", ... })`) would widen to `{ type: string }`. Fixed
with a single annotated `pipeline` const in `createModelCoreEffect`
(probe-verified that contextual typing flows through `Effect.gen` into
generator returns) — no per-site annotations needed.

## Validation

- `providerModelFactory.test.ts`: 128/128 pass, file untouched.
- Targeted suites (464 tests / 8 files): `providerService`, `aiService`,
`agentSession.preStreamError`, `agentSession.startupAutoRetry`,
`streamManager`, `coderOauthService`, `codexOauthService`,
`muxGatewayOauthService` — all green.
- `make static-check` green (typecheck both configs, prettier, ESLint,
docs checks).
- Runtime probes against effect v4-rc verified: `Effect.catchDefect`
receives the raw thrown/rejected value (Error and non-Error), and
`Effect.promise` rejections become defects — confirming exact parity of
the fold with the old `try/catch` before conversion.

## Risks

Low-to-moderate: this file constructs every SDK model Xum uses, so a
behavioral regression would be broad. Mitigations: the diff is
mechanical (whitespace-dominant; ~200 substantive lines), all error
routing/branch logic is verbatim, and error-path parity was
probe-verified rather than assumed. One intentional nuance: a *defect*
escaping `resolveAndCreateModel`'s routing section (previously an
ordinary rejection) now rejects through `runPromise` with the original
message preserved; no caller inspects rejection identity (they consume
`Result`), and `createModel`'s catch-everything fold is unchanged.

## Lessons for Phase 3 (background workers/heartbeats/schedulers with
Schedule & Scope)

Runtime-owned loops, timers, and resource lifecycles observed during
this work — candidates for Effect `Schedule`/`Scope` ownership:

- **Per-request OAuth token refresh**
(`codexOauthService.getValidAuth()` / `coderOauthService.getValidAuth()`
inside fetch wrappers): today each request re-enters refresh logic with
cross-process file locks and "tens of seconds" refresh windows (see the
policy-recheck comment in the coder wrapper). A Phase 3 runtime-owned
token-refresh worker with `Schedule` could own renewal proactively, and
the wrappers would only read current credentials.
- **`attachLanguageModelCleanup` / `moveLanguageModelCleanup` +
`webSocketTransport.close`**: manual cleanup registries riding on model
instances (WebSocket transport lifetime) are exactly the shape
`Scope`/acquireRelease is for; `branchSummary.ts` already races model
creation against a deadline and manually runs cleanup on late arrivals —
a scoped resource would make that race safe by construction.
- **`wrapFetchWithMuxGatewayAutoLogout`**: a fire-and-forget config
mutation (`providerService.setConfig`) triggered from inside a fetch
wrapper on 401 — an event-triggered side effect that would be better
modeled as an interruptible, runtime-owned effect than an unawaited
Promise inside a request path.
- **Contextual-typing lesson** (for any future gen conversion):
wire-union literals in `Effect.gen` returns widen without context;
annotate the receiving const/return position instead of adding per-site
generic annotations — TS propagates the context through `Effect.gen`
into generator return statements.
- **`Effect.catchDefect` is the exact analogue of a whole-pipeline
`try/catch`** around mixed sync/async code: no need to thread
`Effect.try`/`tryPromise` tags through every step when callers only
consume a folded wire shape.

---

_Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking:
`xhigh`_

<!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 6, 2026
…cope (coder#4031)

## Summary

Phase 3 of the progressive Effect migration: the periodic-worker
internals of `heartbeatService`, `idleCompactionService`, and
`idleDispatcher` now run on Effect fibers — `Schedule`-driven loops for
the two interval services and forked sleep fibers for per-workspace
debounce — with service start/stop lifecycles owned by an Effect `Scope`
(the first real `Scope` use in the migration). Public APIs are unchanged
and all pre-existing tests pass unchanged.

## Background

Follows coder#4022 (spike), coder#4025 (memory ops), coder#4027 (retryManager + gateway
OAuth), coder#4028 (providerService), coder#4030 (providerModelFactory). coder#4027
established that Effect `Schedule` fits runtime-owned loops (the
repeated effect lives inside the Effect world) rather than
externally-driven state machines — these three services are exactly the
runtime-owned case: hand-rolled `setTimeout` startup delays chained into
`setInterval` ticks, plus per-workspace debounce timers.

## Implementation

**heartbeatService** — the `startupTimeout` + `checkInterval` timer pair
becomes one scheduler fiber: `Effect.sleep(STARTUP_DELAY_MS)` → first
tick → `Effect.repeat(Schedule.fixed(CHECK_INTERVAL_MS))`.
`Schedule.fixed` is the `setInterval` analogue (wall-clock anchored, no
burst catch-up; probe-verified). `start()` acquires the idle-consumer
registration and workspace event listeners via `Effect.acquireRelease`
and forks the scheduler with `Effect.forkIn`, all inside a
`Scope.makeUnsafe()` lifecycle scope; `stop()` closes the scope, which
releases everything in reverse acquisition order — fiber interrupt
(synchronously clearing the pending timer), listeners off, consumer
dispose — the exact order the hand-rolled `stop()` used. The legacy
`startupTimeout`/`checkInterval` field pair is kept (now holding the
fiber) because the null/non-null phase progression is the observable
lifecycle contract that tests pin.

**idleCompactionService** — same shape: `sleep(INITIAL_CHECK_DELAY_MS)`
→ immediate first check → `Schedule.fixed(CHECK_INTERVAL_MS)`, forked
into a lifecycle scope; checks stay fire-and-forget so a slow sweep
never delays a cadence slot (matching `setInterval`).

**idleDispatcher** — each per-workspace debounce `setTimeout` becomes a
forked fiber (`Effect.sleep(debounceMs)` → mark ready). `Effect.runFork`
executes synchronously up to the sleep, so timer registration ordering
relative to `requestDispatch` is unchanged, and a zero-duration sleep
still defers to a timer tick rather than firing inline (probe-verified).

**Behavioral improvement (one new test)** — under the hand-rolled
version, a throw partway through `heartbeatService.start()` leaked
earlier acquisitions (idle-consumer registration) and left the service
permanently wedged (`stopped=false`, so both retry-`start()` and the
duplicate-consumer assert would fire). Scope finalizers now guarantee
release on partial startup failure and the rollback restores the stopped
state, so a retry succeeds. This is the only new test; no other test
files changed beyond that addition.

### Runtime semantics probe (verified against effect 4.0.0-rc.112 before
implementation)

- `Scope.makeUnsafe` + `Effect.acquireRelease` + `Effect.forkIn` under
`Effect.runSync` execute fully synchronously; the forked fiber runs to
its first `sleep` before fork returns.
- `Effect.runSync(Scope.close(...))` completes synchronously while the
fiber is suspended on its clock timer, runs finalizers in reverse order,
and no late ticks fire afterward.
- `Effect.repeat` runs the first execution immediately; `Schedule.fixed`
re-anchors after a slow body without bursting (`[0, 30, {50ms body},
immediate, 120, 150, 180]`).
- Forked `Effect.sleep(0)` defers like `setTimeout(0)` rather than
firing inline.

### Explicitly out of scope (fit assessment)

- **memoryConsolidationService**: not converted here. Its loop is a fit
for the same Schedule/Scope pattern *if* it is a plain delay+interval
worker, but it also participates in memory-file locking; conversion
should be planned together with its lock-ordering constraints rather
than ride along in a scheduling PR.
- **workspaceStatusGenerator**: not converted. Its regeneration is
event/debounce-driven off workspace activity rather than a fixed-cadence
loop; the idleDispatcher debounce-fiber pattern from this PR is the
right template when it is converted.
- Proactive OAuth token-refresh worker (suggested by coder#4030): new
functionality, not migration — remains backlog.

## Lessons for Phase 4 (core router progressive conversion +
streamManager placement)

1. `Scope` + `acquireRelease` + `forkIn` composes cleanly with
**synchronous** start/stop facades: everything runs under `runSync`
because acquisitions are `Effect.sync` and fibers only suspend on clock
timers. Phase 4's streamManager owns fibers that suspend on **I/O**,
where `Scope.close` cannot complete synchronously — plan async `close`
(or `runFork` + stopped-flag latching) for those lifecycles before
converting.
2. `Schedule.fixed` vs `Schedule.spaced`: `fixed` is the honest
`setInterval` replacement (wall-clock anchor, no burst); `spaced` drifts
by body duration. Pick per legacy timer type, and probe — repeat's first
execution is immediate, which conveniently matches the "fire once when
the startup timer lands, then every interval" idiom.
3. Fields that tests pin (`startupTimeout`/`checkInterval`) can survive
a mechanism swap by re-typing them as fiber/phase markers rather than
editing tests; document that the null/non-null progression is the
contract. The core router has many more internals-pinning tests — budget
for this.
4. TS6133 trap: a phase-marker field written only from inside the fiber
counts as "never read" — give it a genuine read (e.g. a shutdown debug
log) instead of suppressing.
5. `type Fiber` import: fibers held only as fields trip
`consistent-type-imports`; import the namespace as type.

## Validation

- Probe scripts against effect 4.0.0-rc.112 validated all
timing/interruption assumptions before implementation (results above).
- `bun test` on the three service suites: 100 pass / 0 fail (99
pre-existing unchanged + 1 new). Consumer suites (workspaceGoalService,
agentSession.goalAutoPause, agentSession.waitForIdle,
workspaceService.heartbeatSettings, tools/heartbeat, timelineMapper,
tools): all green. The single `workspaceService.test.ts` failure ("bash
monitor wakes > accepted history suppresses redelivery…") reproduces
identically on unmodified `main` in this environment (baseline,
unrelated).
- `make static-check` green.

## Risks

Low-to-moderate: heartbeat and idle-compaction scheduling drive
background automation for every workspace, so a cadence regression would
be user-visible but not data-destructive. The queue/eligibility business
logic is untouched — only the timer skeletons moved. The main semantic
risk (synchronous stop ordering) is pinned by existing lifecycle tests
and was probe-verified; interruption of a sleeping fiber clears its
timer synchronously, matching `clearTimeout`/`clearInterval`.
---

_Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking:
`xhigh` • Cost: `$0.00`_

<!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh
costs=0.00 -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 6, 2026
…ateway OAuth procedures (coder#4032)

## Summary

Final phase of the progressive Effect migration roadmap: makes
`handlerGen` the documented default for future oRPC procedures, converts
the two remaining router procedures whose backing service is already
Effect-native (`muxGateway.getAccountStatus`,
`muxGatewayOauth.startDesktopFlow`), and delivers the streamManager
placement decision plus a migration completion audit (below).
Deliberately progressive, not wholesale: procedures backed by Promise
services are left untouched — the convention is to convert the service
surface first, never to wrap Promises in Effect at the router.

## Background

Phases 0–3 landed in coder#4022 (spike/effectBridge), coder#4025 (memory), coder#4027
(retryManager + gateway OAuth internals), coder#4028 (providerService), coder#4030
(providerModelFactory), coder#4031 (heartbeat/idle workers on
Schedule+Scope). `router.ts` has ~315 handler sites; 11 already rode
`handlerGen` (7 `memory.*`, 4 `providers.*` mutations). This PR audits
the remaining 300+, converts exactly the ones whose backing pipelines
already exist as Effect, and encodes the go-forward convention as a
module doc in `router.ts`.

## Implementation

- **`muxGatewayOauthService`**: the Effect pipelines from coder#4027 were
private behind `Effect.runPromise` facades. They are now exposed as
wire-shaped public Effect methods (matching the
`providerService.setConfigEffect` house pattern):
- `getAccountStatusEffect()` — left **interruptible**: the balance fetch
is a pure read, and the session-expired credential clear is a single
best-effort promise that runs to completion even if the fiber is
interrupted while awaiting it (JS promises are not cancelled by fiber
interruption).
- `startDesktopFlowEffect()` — wrapped in `Effect.uninterruptible`
(mirrors `asAtomicMutation` in providerService): a client abort between
loopback-server acquisition and `desktopFlows.register` would otherwise
leak the server with nothing left to close it.
- The Promise facades remain (thin `runPromise` wrappers) so the
existing service tests stay byte-identical; the router no longer calls
them.
- **`router.ts`**: the two procedures ride `handlerGen`; a module doc
codifies the convention (handlerGen default for new unary procedures;
plain handlers only for Promise-backed services pending conversion,
event-iterator subscriptions, and trivial sync reads; audit
abort-atomicity before converting mutations). No lint rule: no cheap
existing rule expresses "async handler in this one file is suspect"
without flagging the ~280 legitimately deferred sites, and building lint
infrastructure is out of scope.

## Conversion audit

**Converted here (2):**

| Procedure | Backing surface | Abort semantics |
| ---------------------------------- |
--------------------------------------------- |
-------------------------------------------------------------------------
|
| `muxGateway.getAccountStatus` | `muxGatewayOauthService` (Effect since
coder#4027) | interruptible read; best-effort credential clear is
single-promise atomic |
| `muxGatewayOauth.startDesktopFlow` | `muxGatewayOauthService` |
`Effect.uninterruptible` — prevents loopback-server leak on client abort
|

**Already on handlerGen (11):**
`memory.list/read/save/delete/setPinned/consolidationStatus/consolidate`
(coder#4025),
`providers.addCustomProvider/removeCustomProvider/setProviderConfig/setModels`
(coder#4028). Total after this PR: **13**.

**Audited and deferred (with reasons):**

| Procedure group | Backing surface | Why deferred |
|
--------------------------------------------------------------------------------------------------------------------------
| ----------------------------------------------------- |
------------------------------------------------------------------------------------------------------------------------------
|
| `muxGatewayOauth.waitForDesktopFlow` / `cancelDesktopFlow` |
`OAuthFlowManager` (promise-native deferred registry) | Needs the
OAuthFlowManager Scope conversion (coder#4027 backlog) first; wrapping its
Promises in Effect at the router adds no value |
| `providers.list` / `getConfig` | `providerService` sync reads |
Deliberately plain per coder#4028 (trivial sync reads) |
| `providers.updateRoutePreferences` | Delegates to `Config` (Promise) |
Config service conversion first |
| All subscriptions (~24: `subscribe*`, `onChange`, `onConfigChanged`,
terminal/chat streams) | Event iterators | `handlerGen` cannot produce
event iterators; blocked on an Effect Stream bridge (existing backlog
item from coder#4025) |
| `config.*` (19 sites) | `Config` (Promise) | Highest-fan-in single
service; best next conversion target |
| `projects.idleCompaction.get/set`, `workspace.heartbeat.set` |
`projectService` / `workspaceService` settings stores | The
Effect-native workers (coder#4031) _consume_ these settings; the settings
_stores_ are Promise services |
| `codexOauth`/`copilotOauth`/`coderOauth`/`muxGovernorOauth` (~18
sites) | Promise OAuth services | Same shape as gateway OAuth; natural
batch after OAuthFlowManager grows a Scope surface |
| `workspace.*` (~47), `projects.*` (~23), `mcp*` (~26), `terminal`,
`analytics`, `backup`, `update`, remaining (~200 total) | Promise
services | Deep service conversions; out of Phase 4 scope by design |

## streamManager placement decision

**Recommendation: defer wholesale conversion; migrate by seams, starting
with the two lifecycle seams below.** (Analysis of the 5,281-line file,
informed by the coder#4031 lesson that `runSync(Scope.close(...))` only
composes when fibers suspend on clock timers.)

Why wholesale conversion is wrong right now:

1. **Fiber interruption vs `AbortController` mismatch.** streamManager
sits on AI SDK v5 `streamText`, cancelled via Web `AbortSignal`
(per-stream controllers allocated in `startStream`, polled every
`fullStream` iteration). Interrupting a fiber does not cancel the SDK
network stream; every one of the ~30 abort touchpoints would need
dual-cancellation glue (Scope finalizer → `abort()` and signal →
interrupt).
2. **I/O-suspending loops need async close.** The `fullStream`
consumption loop (`processStreamWithCleanup`, ~750 lines) suspends on
network I/O and tool execution — exactly the case where coder#4031 showed
synchronous `Scope.close` cannot work. Teardown must be
`runPromise`-based with stopped-flag latching, otherwise late chunks
race new streams in the same workspace slot and can corrupt
`partial.json`.
3. **Monolithic mutable state.** `WorkspaceStreamInfo` carries 30+
interconnected fields (parts accumulation, step tracker, usage
accumulators, fallback chains, pending tool buffers, throttle timers)
mutated across four phases; a single-pass rewrite would touch hundreds
of transitions at once.
4. **Push-based event sink.** `TurnEngineEventSink` pushes to
`AIService`/`AgentSession`/IPC; bridging to Effect `Stream`/`Hub` forces
cross-layer churn in three consumers.

Proposed seam map for incremental follow-up (in order):

| Seam | Today | Effect shape | Test exposure |
|
-------------------------------------------------------------------------------------------
| ---------------------------------------------- |
------------------------------------------------------------------------------------
|
----------------------------------------------------------------------------------
|
| 1. Stream temp-dir lifecycle
(`createTempDirForStream`/`cleanupStreamTempDir`) | manual create/delete
with double-cleanup guard | `Effect.acquireRelease` in a per-stream
Scope | behavioral only; no pinned internals |
| 2. Partial-write debounce (`schedulePartialWrite`/`flushPartialWrite`)
| 500 ms `setTimeout` + promise chaining | debounce fiber
(`Effect.sleep` + interrupt), same template as idleDispatcher (coder#4031) |
behavioral only; `partialWriteTimer` not pinned |
| 3. Error categorization + lost-response-id registry
(`categorizeError`, `isResponseIdLost`) | plain functions + `Set` |
`Schema.TaggedError` classification pipelines; `Ref` for the registry |
`isResponseIdLost` asserted directly; one test pins `createStreamResult`
via cast |
| 4. Usage accounting (`recordSessionUsage`,
`resolveTotalUsageForStreamEnd`) | async methods | `Effect.gen`
pipelines | one test pins `tokenTracker` field (re-type as marker per
coder#4031 lesson if swapped) |

Seams 1–2 are the coder#4031 patterns verbatim and are safe first steps; the
outer stream engine (reader loop, retry/fallback chains, event sink)
should convert last, if ever, and only after seams shrink it.

## Migration completion state

Effect-native today: `memoryOperations`/`memoryMeta` (coder#4025),
`retryManager` + `muxGatewayOauthService` (coder#4027, public Effect surface
as of this PR), `providerService` mutations (coder#4028),
`providerModelFactory` (coder#4030),
`heartbeatService`/`idleCompactionService`/`idleDispatcher` (coder#4031).
Router: 13/~315 sites on handlerGen; every remaining site is either a
subscription (Stream bridge backlog) or backed by a Promise service.

Suggested future order (value ÷ risk):

1. **OAuthFlowManager Scope conversion** (coder#4027 backlog) → unlocks
`waitForDesktopFlow`/`cancelDesktopFlow` plus the four sibling OAuth
services (~20 router sites) as mechanical batches.
2. **Config service** — highest router fan-in (19 direct sites plus
indirection from providerService/settings stores); single mutation
surface with existing file-lock discipline.
3. **memoryConsolidationService / workspaceStatusGenerator** — direct
fits for the coder#4031 Schedule/dispatcher templates (noted in the Phase 3
report); plan around memory file-lock ordering.
4. **Effect Stream bridge for event iterators** — unblocks all ~24
subscription procedures and the `memory.onChange` backlog item.
5. **streamManager seams 1–4** (above), then reassess the engine core.
6. **workspaceService / projectService / taskService** — deepest and
widest; last.

## Validation

- `make static-check` green; `bun test
src/node/services/muxGatewayOauthService.test.ts
src/node/orpc/effectBridge.test.ts src/node/orpc/router.test.ts` — 23
pass, 0 fail, tests unchanged.
- Gateway OAuth service tests exercise the converted pipelines through
the retained facades (`runPromise` over the same Effects the router now
yields), covering the session-expired credential-clear path and
desktop-flow start/callback/exchange.

## Risks

Low. Wire contracts, schemas, and service behavior are unchanged; the
two converted procedures execute the same Effect pipelines as before,
now directly on the oRPC fiber instead of behind `runPromise`. The one
intentional semantic change: client aborts can now interrupt
`getAccountStatus` mid-fetch (previously it always ran to completion) —
safe for a read; the credential-clear write is single-promise atomic.
`startDesktopFlow` is explicitly uninterruptible, so its abort behavior
is identical to before.

---

_Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking:
`xhigh` • Cost: `$0.00`_

<!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh
costs=0.00 -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 6, 2026
…w Scope (coder#4033)

## Summary

Phase 5 of the progressive Effect migration (first phase of Wave 2, the
wave's gating item deferred since coder#4027): converts `OAuthFlowManager`
internals to Effect with real resource safety. Every registered desktop
OAuth flow now owns a per-flow `Scope` whose release finalizers
guarantee cleanup (registration-timeout clear, deferred settlement,
loopback-server close) on every termination path — finish, cancel,
caller-timeout race, duplicate registration, `shutdownAll`, and defects.
The Promise-based public API is preserved as thin `Effect.runPromise`
facades, so the three not-yet-converted OAuth services
(`coderOauthService`, `codexOauthService`, `muxGovernorOauthService`)
and all existing tests work unchanged.

## Background

Wave 1 (coder#4022, coder#4025, coder#4027, coder#4028, coder#4030, coder#4031, coder#4032) established the
house pattern: `Effect.gen` internals, thin `runPromise` facades,
`handlerGen` for oRPC procedures. coder#4027 converted
`muxGatewayOauthService` but explicitly deferred the shared
flow-lifecycle manager: its resources (loopback `http.Server`,
registration `setTimeout`, result deferred) were cleaned up via ad-hoc
`try/catch` + fire-and-forget `void closeServer(...)`, and a defect
while resolving the deferred silently skipped the server close. This PR
is the acquire/release case that deferral pointed at, and unblocks Phase
6 (batch conversion of the sibling OAuth services).

## Implementation

**Per-flow Scope design** — `register` creates a `Scope.makeUnsafe()`
per flow and moves ownership of the caller-acquired resources into it
via one `Effect.acquireRelease` per resource (a combined acquisition
would install its finalizer only after every step succeeded, leaking
earlier resources on a later defect — the coder#4031 Codex P2 lesson).
Release runs in reverse acquisition order, preserving the pre-Effect
`finish` ordering: clear registration timeout → settle deferred (waiters
unblock before the async close) → close loopback server (awaited).

**Deferred settlement via finalizer** — each `ActiveFlow` carries a
mutable `finalResult` staged by the terminating path
(finish/cancel/shutdown/replace); the settle finalizer resolves the
caller's deferred with it. Settlement is therefore scope-guaranteed
rather than an ad-hoc `resolve` call, with a defensive fallback result
so waiters can never hang.

**Caller-facing timeout race** — `waitFor` maps to `Effect.timeout` over
`Effect.promise` on the shared deferred: the local wait timer is
fiber-managed (interruption clears it), stays separate from the
registration-time timeout, and on any error result runs `finish` for
shared cleanup. The cleanup's synchronous bookkeeping (map removal,
completed-result recording) runs before `waitFor` resolves — exact
parity with the old sync prefix — while the async release runs in an
`Effect.forkDetach` fiber, replacing the old `void this.finish(...)`
fire-and-forget with a supervised fiber that survives the caller's
completion (verified by a live-runtime probe: detached fibers outlive
the parent, `runFork`/`runPromise` execute synchronously to first
suspension, and a throwing finalizer does not skip its siblings).

**shutdownAll contract** — preserved as async (`Promise<void>` facade):
`serviceContainer.dispose` awaits it, and loopback-server closes are
bounded by the server's force-finish socket handling. It never rejects;
release defects are caught (`Effect.catchDefect`) and logged at debug
level, per the startup/shutdown-must-never-crash rule.

**Effect-native surface** — `waitForEffect` / `cancelEffect` /
`finishEffect` / `cancelAllEffect` / `shutdownAllEffect` are public
(wire-shaped, never-failing — same shape as coder#4032's Effect surfaces).
`muxGatewayOauthService`'s Effect pipeline now yields `finishEffect`
directly instead of `Effect.promise(() => …finish(...))`, and its
registration-timeout callback uses `Effect.runFork(finishEffect(...))`
instead of `void finish(...)`.

**Not converted to Effect `Deferred`** — the result deferred's identity
is part of the public caller-owned `OAuthFlowEntry` (the three
unconverted services construct entries with `createDeferred`), so
swapping it would break the "existing callers unchanged" contract;
revisit when Phase 6 converts entry construction.

## Validation

- All 18 pre-existing `oauthFlowManager` tests pass byte-identical, plus
all OAuth service suites (194 tests:
coder/codex/muxGateway/muxGovernor/mcp/copilot/codexOauthAuth) and
loopback-server/oauthUtils suites.
- Two new behavioral tests for the genuinely-new guarantees: (1) server
close + timeout clear still happen when the deferred `resolve` throws
(the pre-Effect code skipped the close — this test fails on the old
implementation), and (2) the detached cleanup fiber completes after
`waitFor` has already returned on the timeout path (guards against
accidental child-fiber supervision, where the release would be
interrupted with the caller).
- A standalone Effect v4 runtime probe validated the semantics the
design relies on (finalizer independence under defects, reverse
sequential release order, eager sync-prefix execution of
`runPromise`/`runFork`, `forkDetach` outliving the parent,
`Effect.timeout` + `Effect.catch` over `Effect.promise`).
- `make static-check` green.

## Risks

Low-to-moderate: this is shared lifecycle code under four OAuth login
flows (Gateway, Governor, Codex, Coder). The public API, observable
ordering (map removal before `finish` resolves, deferred settlement
before server close, synchronous `register`), and error strings are
preserved exactly; regressions would surface as leaked loopback
listeners, hung `waitFor` calls, or unsettled deferreds — all covered by
the existing + new suites.

## Lessons for Phase 6

Phase 6 is the batch conversion of `coderOauthService`,
`codexOauthService`, `muxGovernorOauthService`, `copilotOauthService`,
plus their ~20 router sites. Notes to make it mechanical:

- The manager now exposes never-failing, wire-shaped
`waitForEffect`/`cancelEffect`/`finishEffect`/`shutdownAllEffect`, so
converted service pipelines can yield them directly (see
`desktopCallbackPipeline` in `muxGatewayOauthService` as the template),
and registration-timeout callbacks should use
`Effect.runFork(manager.finishEffect(...))`.
- `beginFinish`'s sync-bookkeeping/async-release split is the pattern to
reach for wherever a service needs "unregister now, release in
background" semantics.
- Each sibling's `startDesktopFlow` should become uninterruptible like
the gateway's (coder#4032): a client abort between loopback acquisition and
`register` would otherwise leak the server.
- `coderOauthService` is the outlier: it has extra commit-path liveness
checks (`has`) and multi-step persist/commit finish calls (~10
`desktopFlows.*` sites vs ~5 in the others) — expect most of the Phase 6
effort there.
- **Recommendation: two PRs.** PR A: codex + governor + copilot service
internals (near-identical DesktopFlow shape, mechanical) together with
their router procedures moving to `handlerGen` (the `waitFor`/`cancel`
handlers for the gateway can join here — the router comment at
`muxGatewayOauth` already points at this). PR B: `coderOauthService`
alone — its commit/persist liveness semantics deserve isolated review,
and a combined PR would bury it under the mechanical churn.
---

_Generated with [`mux`](https://github.com/coder/mux) • Model:
`anthropic:claude-fable-5` • Thinking: `xhigh`_

<!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant