Skip to content

🤖 refactor: adopt handlerGen as the oRPC router default and convert gateway OAuth procedures - #4032

Merged
ThomasK33 merged 1 commit into
mainfrom
effect-phase4-router-progressive
Sep 1, 2026
Merged

🤖 refactor: adopt handlerGen as the oRPC router default and convert gateway OAuth procedures#4032
ThomasK33 merged 1 commit into
mainfrom
effect-phase4-router-progressive

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

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 #4022 (spike/effectBridge), #4025 (memory), #4027 (retryManager + gateway OAuth internals), #4028 (providerService), #4030 (providerModelFactory), #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 🤖 refactor: convert retryManager and muxGatewayOauthService internals to Effect #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 #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 (#4025), providers.addCustomProvider/removeCustomProvider/setProviderConfig/setModels (#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 (#4027 backlog) first; wrapping its Promises in Effect at the router adds no value
providers.list / getConfig providerService sync reads Deliberately plain per #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 #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 (#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 #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 🤖 refactor: convert periodic-worker scheduling to Effect Schedule + Scope #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 (#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 #4031 lesson if swapped)

Seams 1–2 are the #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 (#4025), retryManager + muxGatewayOauthService (#4027, public Effect surface as of this PR), providerService mutations (#4028), providerModelFactory (#4030), heartbeatService/idleCompactionService/idleDispatcher (#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 (🤖 refactor: convert retryManager and muxGatewayOauthService internals to Effect #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 🤖 refactor: convert periodic-worker scheduling to Effect Schedule + Scope #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

@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

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 f281bff Sep 1, 2026
35 of 38 checks passed
@ThomasK33
ThomasK33 deleted the effect-phase4-router-progressive branch September 1, 2026 00:03
@mux-bot mux-bot Bot mentioned this pull request Sep 1, 2026
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