🤖 refactor: convert periodic-worker scheduling to Effect Schedule + Scope - #4031
Merged
Conversation
heartbeatService and idleCompactionService now run their startup-delay + interval loops as Schedule-driven Effect fibers owned by a lifecycle Scope; idleDispatcher debounce timers become forked sleep fibers. Observable behavior (cadence, first-fire timing, debounce coalescing, synchronous start/stop ordering) unchanged; start-failure cleanup is now guaranteed via scope finalizers.
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.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f683a0592c
ℹ️ 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".
…the activity listener (Codex P2)
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 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 3 of the progressive Effect migration: the periodic-worker internals of
heartbeatService,idleCompactionService, andidleDispatchernow 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 EffectScope(the first realScopeuse in the migration). Public APIs are unchanged and all pre-existing tests pass unchanged.Background
Follows #4022 (spike), #4025 (memory ops), #4027 (retryManager + gateway OAuth), #4028 (providerService), #4030 (providerModelFactory). #4027 established that Effect
Schedulefits 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-rolledsetTimeoutstartup delays chained intosetIntervalticks, plus per-workspace debounce timers.Implementation
heartbeatService — the
startupTimeout+checkIntervaltimer pair becomes one scheduler fiber:Effect.sleep(STARTUP_DELAY_MS)→ first tick →Effect.repeat(Schedule.fixed(CHECK_INTERVAL_MS)).Schedule.fixedis thesetIntervalanalogue (wall-clock anchored, no burst catch-up; probe-verified).start()acquires the idle-consumer registration and workspace event listeners viaEffect.acquireReleaseand forks the scheduler withEffect.forkIn, all inside aScope.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-rolledstop()used. The legacystartupTimeout/checkIntervalfield 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 (matchingsetInterval).idleDispatcher — each per-workspace debounce
setTimeoutbecomes a forked fiber (Effect.sleep(debounceMs)→ mark ready).Effect.runForkexecutes synchronously up to the sleep, so timer registration ordering relative torequestDispatchis 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.forkInunderEffect.runSyncexecute fully synchronously; the forked fiber runs to its firstsleepbefore 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.repeatruns the first execution immediately;Schedule.fixedre-anchors after a slow body without bursting ([0, 30, {50ms body}, immediate, 120, 150, 180]).Effect.sleep(0)defers likesetTimeout(0)rather than firing inline.Explicitly out of scope (fit assessment)
Lessons for Phase 4 (core router progressive conversion + streamManager placement)
Scope+acquireRelease+forkIncomposes cleanly with synchronous start/stop facades: everything runs underrunSyncbecause acquisitions areEffect.syncand fibers only suspend on clock timers. Phase 4's streamManager owns fibers that suspend on I/O, whereScope.closecannot complete synchronously — plan asyncclose(orrunFork+ stopped-flag latching) for those lifecycles before converting.Schedule.fixedvsSchedule.spaced:fixedis the honestsetIntervalreplacement (wall-clock anchor, no burst);spaceddrifts 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.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.type Fiberimport: fibers held only as fields tripconsistent-type-imports; import the namespace as type.Validation
bun teston 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 singleworkspaceService.test.tsfailure ("bash monitor wakes > accepted history suppresses redelivery…") reproduces identically on unmodifiedmainin this environment (baseline, unrelated).make static-checkgreen.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