🤖 refactor: convert memoryConsolidationService and workspaceStatusGenerator internals to Effect; make in-flight run-lock reservation deterministic - #4038
Merged
Conversation
…ator internals to Effect; fix in-flight reservation race
This comment has been minimized.
This comment has been minimized.
Member
Author
|
@codex review |
Member
Author
|
@codex security review |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
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 8 of the progressive Effect migration (after #4033/#4034/#4035/#4036): converts
memoryConsolidationServiceandworkspaceStatusGeneratorinternals to Effect-native pipelines behind unchanged Promise facades, and fixes the"rejects a second trigger while a run is still in flight"5s-timeout flake that evicted #4035 from the merge queue — by making the in-flight run-lock reservation deterministic.Background
Wave 1 (#4031) established the worker templates (Schedule-driven fibers, runFork sleep fibers); Wave 2 converted the OAuth services and Config. This phase covers the two remaining Wave 2 services:
memoryConsolidationService(~1100 ln): trigger-driven orchestration around the dream/harvest runners, with a sidecarMutexMapand per-workspace in-flight promise maps.workspaceStatusGenerator(~277 ln): the sidebar-status candidate retry loop with a bounded usage read (previouslyPromise.race+setTimeout— converted to the timeout/sleep-fiber shape from the idleDispatcher template).Flake root cause (chartered fix)
maybeRunawaited the durable workspace-removal tombstone (fsPromises.accesson the libuv threadpool) before the synchronous in-flight check-and-reserve. Two near-simultaneous triggers both suspended on that probe, and threadpool completion order — not call order — decided which caller reserved the run lock. Mutual exclusion always held (the check-and-reserve itself is one microtask), but the winner was nondeterministic.In the test, when the second trigger won the reservation, the first returned the "in flight" refusal and the test then awaited the second run — which was gated on a model-creation promise the test only releases after asserting the second call failed. Deadlock → 5s bun timeout. Same hazard existed in
maybeHarvestThenSweep(boundary-key coalescing) and in the sibling"queues an archive trigger"test.Fix (code, not test): the funnels now check only the synchronous in-process teardown mark (
removalCancelled) before the check-and-reserve, so reservation order is decided purely by call order in one synchronous frame; the durable cross-process tombstone probe moved behind the reservation, to the top of the locked pipelines (runLockedEffect/harvestThenSweepLockedEffect), preserving the same refusal messages and r60/r61 teardown coverage. No awaits were added between any liveness check and its write. The previously-flaky tests now pass deterministically (stressed 25×; timeouts unchanged).Implementation
memoryConsolidationService — Effect.gen pipelines with thin
Effect.runPromisefacades; wireResult<_, string>skip/record unions stay in the success channel:MutexMapcritical sections remain callback-owned Promise seams with byte-identical interiors;saveRecordEffect/saveHarvestRecordEffectareEffect.uninterruptiblemutations wrapping those seams. TheinFlight/harvestInFlightreservation maps remain synchronous try-lock seams in the funnels (documented in the module header andmaybeRundoc) — the funnels stay plain async methods because the check-and-reserve atomicity is load-bearing.runLockedEffect,harvestThenSweepLockedEffect+runHarvestAttemptEffect(the old try-block),recoverRetryableHarvestsEffect,runLaunchSweepEffect(composesmetaService.effects.getEntries()directly),loadEffect/getRecordEffect/getStatusEffect.cancelInFlightConsolidationEffect: teardown isEffect.uninterruptibleend-to-end (r61 mark → abort loop → residual handoff), with the bounded drain explicitlyEffect.interruptible+Effect.timeout(it is a wait; the oldPromise.race+setTimeouttimer is gone).triggerInBackground/triggerHarvestThenSweepInBackground: detachedEffect.runForkfibers (idleDispatcher template). runFork executes synchronously up to the first suspension, so trigger-call ordering of reservations is preserved from the old void-promise chains.workspaceStatusGenerator —
generateWorkspaceStatuskeeps its exact Promise export (agentStatusService tests spy this module symbol withmockResolvedValue; the spy seam pins the facade). Per-candidate attempts are oneattemptCandidatepipeline: the old whole-attempt try/catch/finally becomesEffect.catch+Effect.catchDefectfolds (any failure tries the next candidate — no defect escapes the facade where the old code caught) +Effect.ensuringforrunLanguageModelCleanup. The 2s usage-read race is nowEffect.timeout.Interruption posture: mutations (
saveRecordEffect,saveHarvestRecordEffect) uninterruptible; teardown (cancelInFlightConsolidationEffect) uninterruptible with an explicitly interruptible bounded wait; waits (usage read, drain) interruptible so timeouts work; reads (load/getRecord/getStatus) don't-care. Nothing externally interrupts these fibers today (all entry points are runPromise/runFork facades); the posture is for composition safety.Catch-discipline audit: paths uncaught pre-Effect still reject through facades via
Effect.promisedefects (v4 rethrows raw errors); paths inside old try/catch blocks are folded (attemptCandidate,runHarvestAttemptEffectincl. the completed-record save whose rejection previously fell into the same catch,loadEffectself-healing with parsing inside the caught thunk, per-iteration.catchfolds in recovery/launch-sweep).Router sites: none exist for these services (
memoryConsolidationServicereaches oRPC only via thestatusChangeEventEmitter subscription inrouterSubscriptions.ts, unchanged; the Effect Stream bridge for subscriptions is Phase 9).Validation
make static-checkgreen;memoryConsolidationService(40),workspaceStatusGenerator(5),agentStatusService(36) and allmemory*suites (164) pass unchanged — zero test-file edits.Risks
Lessons for Phase 9 (Effect Stream bridge, ~24 subscriptions incl. memory.onChange)
memoryConsolidationServiceemitsstatusChange+analyticsIngestvia EventEmitter;routerSubscriptions.tsconsumesstatusChangethrough the on/off +asyncIterableFromSubscriptionpush seam. The emit sites are now inside Effect pipelines (saveRecordEffectemits after the lock releases), so bridging toStreamcan hook those seams directly — but note emits are synchronous post-write; a Stream bridge must preserve emit-after-durable-write ordering.recordUsagecallbacks (emit("analyticsIngest")from inside provider-stream callbacks) fire from non-Effect contexts (AI SDK callbacks); the bridge needs a queue that accepts synchronous emissions from foreign callsites, not just fiber-context offers.agentStatusService.test.tsspiesgenerateWorkspaceStatusviaspyOn(module, "name")— module functions consumed through namespace imports must keep Promise signatures.Generated with
xum• Model:anthropic:claude-fable-5• Thinking:xhigh