🤖 refactor: convert providerService mutation internals to Effect - #4028
Merged
Conversation
Phase 2b of the progressive Effect migration. Effect.gen pipelines behind thin Effect.runPromise Promise facades; provider oRPC mutations ride handlerGen. Public API and observable behavior unchanged; all existing tests pass unmodified.
Member
Author
|
@codex review |
Member
Author
|
@codex security review |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 412dcec3d7
ℹ️ 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".
This comment has been minimized.
This comment has been minimized.
Codex P2: a client abort interrupting the handler fiber mid-lock could persist the providers.jsonc write while skipping notify/lifecycle/repair steps. asAtomicMutation (Effect.uninterruptible) restores the pre-Effect run-to-completion semantics; red/green test added.
Member
Author
|
@codex review Addressed the atomicity finding: mutation pipelines are now uninterruptible (asAtomicMutation) with a red/green interruption test. Please take another look. |
Member
Author
|
@codex security review |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2b of the progressive Effect migration: converts the mutation internals of
ProviderServiceto Effect while keeping the public API and observable behavior byte-identical via thinEffect.runPromisefacades. All 105 existingproviderService.test.tstests pass unchanged, and the provider oRPC mutation procedures now ridehandlerGenso client aborts interrupt the fiber.Background
Follows #4022 (Effect v4-rc + oRPC 1.14 spike,
effectBridge/handlerGen), #4025 (memory subsystem), and #4027 (retryManager + muxGatewayOauthService). Same house pattern:Effect.geninternals,Schema.TaggedErroronly where callers genuinely branch, Promise facades so pre-Effect callers (codexOauthService,coderOauthService,muxGatewayOauthService, tests) stay untouched. Phase 2c ownsproviderModelFactory.ts;streamManager.tsplacement is decided at Phase 4.Implementation
Converted pipelines (each is an
Effect.genprogram; the wireResultunions stay in the success channel exactly as before):addCustomProvider/removeCustomProvider(public*Effectvariants exported for the router)setModels/setConfig(public*Effectvariants exported for the router)setConfigValue/updateConfigValue/updateProviderSection(private*Effectinternals; Promise facades remain the entry points for the OAuth services)syncGatewayLifecycle→syncGatewayLifecycleEffect: the synchronous config/policy read now computes the main-config edit to apply (or null) inside one guardedEffect.try, followed by a guardededitConfigafterAppliedMutation→afterAppliedMutationEffect: best-effort semantics preserved viaEffect.catch+log.errorError tags: exactly one —
ProviderPersistenceError { message }, carrying thegetErrorMessage(cause)string. No caller branches on which write failed, only on the folded wireResult, so per-methodEffect.catchTagfolds reproduce the old per-method try/catch strings (persistence_failedcodes,Failed to set models: …,Failed to set/update provider config: …).removeCustomProviderfolds twice at different pipeline points because its lock failure (persistence_failed) and config-repair failure (config_repair_failed, with the notify-before-return side effect) carry different wire codes — matching the original two try/catch blocks.Deliberately not converted:
list,getConfig,validateRouteOverrides): no async composition, so Effect conversion would add fiber overhead without benefit (noted in the module doc comment).onConfigChanged, fingerprint self-write suppression): not a fallible pipeline.updateRoutePreferences: thin delegation toConfig.providers.onConfigChangedsubscription: event iterator, awaits an Effect Stream bridge (existing backlog item).OAuthFlowManager: shared by 4 OAuth services; its Scope conversion stays deferred (per 🤖 refactor: convert retryManager and muxGatewayOauthService internals to Effect #4027).Router:
addCustomProvider,removeCustomProvider,setProviderConfig,setModelsnow delegate throughhandlerGento the fully-handled (Effect<A, never>) service effects — one-linereturn yield*handlers, wire contracts unchanged.Security note: key material handling is unchanged — the tagged error carries only
getErrorMessage(cause)strings (the exact strings the old catch blocks already surfaced), and no secrets are added to logs or error payloads.Validation
make static-checkgreen locally.providerService.test.ts(105),providerModelFactory.test.ts+muxGatewayOauthService.test.ts(138),agentSession.preStreamError+agentSession.startupAutoRetry(57),streamManager.test.ts(119),coderOauthService+codexOauthService(102).editConfigmock, throwing lock callbacks) exercise the newEffect.tryPromise/Effect.tryerror channels and pass unchanged, including the notify-once-on-repair-failure ordering assertion.Risks
Medium-touch refactor of the single service that owns providers.jsonc writes (credentials, models, routePriority). Mitigations: the wire
Resultshapes, lock scoping, notify ordering, and best-effort post-write semantics were preserved statement-for-statement; the 105-test suite covers every mutation path including policy revalidation inside the lock, gateway lifecycle sync, and partial-failure orderings. Highest residual risk is subtle rejection-value changes on previously-unguarded sync throws (now surfaced as FiberFailure viarunPromise) — these paths were already crash paths with no callers branching on the rejection value.Lessons for Phase 2c (
providerModelFactory.ts, ~2953 lines)providerServiceneeded exactly 1 tag across 7 pipelines. ExpectproviderModelFactoryto be similar — it mostly reads config and constructs SDK model instances, so its error taxonomy is probablyResult<_, string>all the way.syncGatewayLifecycleEffectwraps the synchronous decision logic in oneEffect.trythat returns the async edit to apply (or null). This preserves comments and logic verbatim while giving clean yield points. Useful for factory functions that compute options synchronously then await credential resolution.Effect.tryaround reload+notify blocks: when an old try/catch covered trailing sync work (reload, emitter notify), wrap that segment in a singleEffect.tryreturning the wire result rather than splitting into multiple yields — smaller diff, identical coverage.Effect.tryPromiseremain essential:spyOn(...).mockImplementationOnce(() => { throw ... })produces synchronous throws from Promise-typed methods;try: async () => …routes them into the error channel exactly like the oldawait. Two providerService tests depend on this.async:public foo(): Promise<T> { return Effect.runPromise(this.fooEffect()); }passes lint here and avoids require-await debates.providerModelFactoryconsumesProviderServicevia syncgetConfig()/loadProvidersConfig()reads plus async credential resolution — the Effect seam should sit at credential/token fetch boundaries (resolveProviderCredentials, OAuth token refresh), not at the sync config plumbing.Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh• Cost:$9.99