🤖 refactor: convert Config service mutation surface to Effect Semaphore pipeline and config router sites to handlerGen - #4036
Merged
Conversation
…t, config router sites to handlerGen
This comment has been minimized.
This comment has been minimized.
Member
Author
|
@codex review |
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.
Member
Author
|
@codex review |
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 Sep 1, 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 7 of the progressive Effect migration (Wave 2): converts the Config service mutation surface to an Effect
Semaphore(1)-serialized pipeline, converts the fallible read/write pipelines inProvidersConfigStore/SecretsStoreto Effect per their pre-Effect catch discipline, and moves all 20 config-backed unary router procedures (splashScreens,config,uiLayouts) tohandlerGen. All public Promise/sync APIs are preserved by thinrunPromise/runSyncfacades; existing tests pass unchanged.Background
Follows #4033 (OAuthFlowManager per-flow Scope), #4034 (codex/governor/copilot OAuth + 15 router sites), #4035 (coderOauthService). The Config class serializes all mutations through a private promise-chain queue feeding a private
saveConfig— the module docs record the lost-update history (stale-snapshot writes resurrecting removed workspaces). This phase re-expresses that serialization in Effect while preserving those protections exactly.Implementation
Mutation-surface conversion (
src/node/config/index.ts)editConfigQueuepromise chain →Semaphore.makeUnsafe(1)(editSemaphore). FIFO permits map the old chain 1:1: each edit's read happens only after the previous edit's write landed, and a failed edit releases its permit on the way out (the old "keep the queue alive on failure" behavior).enqueueConfigEditstays the only Promise entry point (publiceditConfigis widely spied/overridden in tests, so named mutators keep routing through it unchanged). It defers the fiber start by one microtask: Effect v4runPromiseexecutes fibers synchronously until the first async boundary, but the old chain always ran edit bodies on a later microtask, andloadConfigOrDefault's one-shotmigrationPersistguard depends on the body observing the guard assignment (otherwise load-time migrations would double-schedule their write-back).Effect.trystep immediately followed by thesaveConfigyield — no new await points between the check and the write it approves.saveConfigstays private and keeps its Promise signature (tests spy on it with Promise mocks to simulate swallowed writes); its body moves tosaveConfigEffect, anEffect.Effect<void>with a whole-pipelineEffect.catch+Effect.catchDefectfold mirroring the old total try/catch (log-and-swallow; never fails). The serialized pipeline routes the write through the facade so spies keep intercepting it. Load-time-migration write-back semantics (identity transform re-run under the serialized pipeline) are untouched.runPromise/runSyncreject/throw with the original error for both typed failures and defects (verified empirically), soeditConfigcallers and tests observe byte-identical rejections.Catch-discipline classification (stores)
Config.saveConfigcatch+catchDefectfold →Effect<void>(never fails)Config.enqueueConfigEditEffect.trywithcatch: (e) => e; raw error to caller via facadeProvidersConfigStore.loadProvidersConfignull(logged)Effect.try+catchfold →Effect<ProvidersConfig | null>ProvidersConfigStore.getProvidersFileFingerprintnull(silent)Effect.try+catchfold →Effect<string | null>ProvidersConfigStore.saveProvidersConfigEffect.try+tapErrorlog; raw failure viarunSyncfacadeProvidersConfigStore.watchProvidersFileSecretsStore.loadSecretsConfig/loadRawSecretsConfig{}(logged)Effect.try+catchfoldSecretsStore.saveSecretsConfigEffect.tryPromisethunk +tapError; raw rejection viarunPromisefacadeSecretsStore.updateSecretsBucket(+updateGlobalSecrets/updateProjectSecrets)Effect.gencomposing load → sync bucket fold → saveSecretsStore.getEffectiveSecrets/getGlobalSecretsetc.Legacy-data passthrough (unsupported secret entries, legacy bestOf metadata, unknown fields) is untouched — the conversion moves control flow only.
fileLeaseManager
Not converted (deliberate). Its lock/lease lifecycle is cross-process (lock directories on disk, PID liveness, stale-breaking, TTLs), not an in-process resource: per the wrap-around-locks doctrine from #4035, Effect wraps around such cross-process critical sections at the caller, never through them. Its Promise seams stay byte-identical.
Router interruption posture (per-procedure)
Reads are single
Effect.syncsteps (interruption is a don't-care: no partial state possible). Mutations wrap the whole pre-Effect handler body in oneEffect.promisethunk, making them uninterruptible by construction: a client abort interrupts the handler fiber but never the in-flight config edit, and multi-step bodies cannot be torn between steps. Rejections become defects → the same internal error the old async handlers produced.splashScreens.getViewedSplashScreensEffect.sync)splashScreens.markSplashScreenViewedconfig.getConfigEffect.sync)config.onConfigChangedconfig.updateAgentAiDefaultsconfig.updateMuxGatewayPrefsconfig.updateRoutePreferencesconfig.updateMinThinkingLevelsconfig.updateModelFallbacksconfig.updateModelPreferencesconfig.updateCoderPrefsconfig.updateRuntimeEnablementconfig.saveConfigconfig.updateChatTranscriptFullWidthconfig.updateLlmDebugLogsconfig.updateHeartbeatDefaultPromptconfig.updateHeartbeatDefaultIntervalMsconfig.updateGoalDefaultsconfig.unenrollMuxGovernoruiLayouts.getAllEffect.sync)uiLayouts.saveAllAdditionally,
enqueueConfigEditEffectitself isEffect.uninterruptible(defense in depth: the corrupt-file gate, write, and change notification form one unit) while waiting for the permit stays interruptible.Validation
runPromise/runSyncraw error identity for failures and defects; Semaphore FIFO + serialization; sync fiber start (motivates the microtask defer).src/node/config.test.ts113/113;src/node/config/34/34 (secretsStore, providersConfigStore, fileLeaseManager);workspaceService.configResurrection.test.ts4/4 (the lost-update contract); router + effectBridge suites; providerService/backup + workspaceService heartbeat/tags/goalDefaults + projectService + updateService + worktreeArchiveSnapshotService consumer suites.tests/ipc/configjest: 7/9 suites green;mcpConfig.test.tsandmodelNotFound.test.tsfailures reproduce identically on the unmodified base (live-AI bridge environment; see Risks note) — verified via stash/run/pop.Risks
Severity: medium (config serialization is the app's central persistence chokepoint). The conversion is control-flow-preserving by construction: same read→gate→write→notify order under the same mutual exclusion, same error contracts (verified empirically for identity), same on-disk serialization (untouched). The main behavioral surface is scheduling: the microtask defer preserves the old chain's assignment-before-body ordering that the migration-persist one-shot guard depends on; the config suite pins this (migration write-back tests).
Lessons for Phase 8 (memoryConsolidationService Schedule conversion + workspaceStatusGenerator dispatcher)
runPromise. Any facade replacing a promise-chain/queue must check whether callers depend on deferred body execution (one-shot guards, listener registration windows) and add an explicit microtask defer if so. This will matter for workspaceStatusGenerator's dispatcher loop.editConfigis spied/overridden across many suites, so the Effect pipeline had to live behind the existing method rather than replacing named mutators with Effect surfaces. CheckspyOn/property-override usage before choosing the Effect-native surface for memoryConsolidationService.Semaphore.makeUnsafe(1)+withPermits(1)maps AsyncMutex/promise-queue semantics 1:1 (FIFO, release-on-failure), noacquireUseReleaseneeded.Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh• Cost:$17.43