refactor(daemon): move singletons into per-daemon layers - #57
Open
FreshlyBrewedCode wants to merge 13 commits into
Open
FreshlyBrewedCode wants to merge 13 commits into
FreshlyBrewedCode wants to merge 13 commits into
Conversation
FreshlyBrewedCode
added this pull request to stack #58
September 20, 2026 13:00
FreshlyBrewedCode
force-pushed
the
38-move-singletons-into-layers
branch
from
September 20, 2026 13:03
fc4520c to
bd395f5
Compare
FreshlyBrewedCode
force-pushed
the
38-move-singletons-into-layers
branch
from
September 20, 2026 13:15
bd395f5 to
50ef18d
Compare
This was referenced Sep 22, 2026
The dedupe registry is now always created via createDedupeRegistry() and passed to consumers. This is the first step in moving the four module-level singletons (dedupe, pubsub, active registry, refresh gates) into per-daemon instances so two daemons can coexist in one process. Part of #38
…bSub() Subscribers are now per-instance, so two daemons in one process do not share event fan-out state. Consumers receive a PubSub instance rather than calling module-level publish/subscribe. Part of #38
Refresh gates are now per-instance via createRefreshGates() and passed to allocateWorkspace. This allows two daemons to maintain independent mirror-refresh queues. Part of #38
…t seams The active-run registry is now per-daemon via createRunRegistry(). startTrackedRun and dispatchChildRun accept DaemonServices (registry, pubsub, dedupeRegistry, refreshGates) as required parameters. Removed optional test-seam fields: - dedupeRegistry? from StartTrackedRunOptions and DispatchEnv - beforeStart? from StartTrackedRunOptions The slot reservation and dedupe-key claim remain synchronous check-then-set with no await between, preserving concurrency safety. Part of #38
SchedulerDeps now requires dedupeRegistry and clock as mandatory fields. The clock comes from Effect's Clock service (currentTimeMillisUnsafe), replacing the bespoke now?: () => number test seam. Tests provide a Clock instance instead of injecting a now function. Part of #38
startDaemon creates DaemonServices (registry, pubsub, dedupe, refresh gates) and passes them to serve() and the scheduler. http.ts receives services as a required ServerOptions field and uses them throughout. This completes the core refactor - two daemons can now coexist in one process with independent state. Tests still need updating to provide services instead of using test seams. Part of #38
Tests now create their own DaemonServices instances instead of using module-level singletons or test seams. The beforeStart test seam is gone - tests use slow workspace allocation or direct registry manipulation to test the reserved-but-not-started window. Scheduler tests use Effect's Clock interface instead of the bespoke now?: () => number test seam. All 250 tests pass. Part of #38
Proves that two daemons can run in one process without sharing run state. Each daemon has its own registry, pubsub, dedupe registry, and refresh gates. Tests verify: - Independent run registries (each daemon sees only its own runs) - Independent pubsub (events don't cross between daemons) - Independent dedupe state (same key can be claimed in both daemons) Part of #38
A 0-byte .tanstack-projected-* file under a stray data/src/factory/... path leaked into 2f97451 from a sandboxed run writing through an absolute path that mirrored the checkout. .gitignore already covers .tanstack-projected-* and data/ (added in PR #55), which is why nothing flagged it since; git rm drops it and its now-empty parent dirs. No other stray data/ or .factory/workspaces/ paths are tracked on this branch. Part of #32
Replace the hand-rolled fake Clock.Clock with effect/testing's TestClock, per issue #38's "tests use TestClock rather than an injected now". tickOnce only ever reads the clock synchronously (currentTimeMillisUnsafe()) and never suspends on Effect.sleep, so none of TestClock's fiber-coordination machinery is exercised here — but building it via Effect.runPromise(Effect.scoped(TestClock.make())) and bridging setTime back into the plain-async fixture (ADR 0009 §5) composes cleanly, so there was no need to keep the fake. Part of #32
FreshlyBrewedCode
force-pushed
the
38-move-singletons-into-layers
branch
from
September 23, 2026 07:08
5a8c2a0 to
0bbf3f1
Compare
This branch has not been deployed
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.
Part of #32 · Closes #38
Four maps lived at module scope —
server/runs.ts's active-run registry,server/pubsub.ts's subscribers,lib/dedupe.ts's exporteddedupeRegistry, andlib/workspace.ts'srefreshGates— which meant two daemons could not coexist in one process and every feature since #13 paid rent on it as another optional test-seam field (dedupeRegistry?,registry?,now?,beforeStart?). #36 gave the daemon a composition root to put them behind; this PR moves them there and retires the seams, without touching the one thing that must not move: the synchronous check-then-set that reserves a registry slot and claims a dedupe key (D24/D29, #15).What changed
createRunRegistry(),createPubSub(),createDedupeRegistry()(already existed) and the newcreateRefreshGates()replace the four module-level singletons; each is a plain closure over aMap, not an Effect service.DaemonServices(registry,pubsub,dedupeRegistry,refreshGates) is built once perstartDaemon()call and threaded explicitly throughstartTrackedRun,dispatchChildRun,makeScheduleFire, and every HTTP handler inserver/http.ts— no daemon reads another daemon's state.dedupeRegistry?,registry?,now?, andbeforeStart?are gone; tests build aDaemonServicesbag (createTestServices()-style helpers) instead of injecting optional fields.Clock.Clockvalue (SchedulerDeps.clock), sourced from acreateLiveClock()indaemon.tsfor production and Effect's realTestClock(effect/testing) inscheduler.test.ts, rather than a bespokenow?: () => number.src/server/two-daemons.test.tsis new: starts two full daemons (startDaemon) in one process, asserts independent run lists, cross-daemon 404s, independentregistry.activeRunIds(), and that the same dedupe key succeeds on both daemons at once — proving the dedupe registries don't share state either.ConcurrencyLimitError/DispatchCapErrorpicked up a mid-stack regression back to plainErrorsubclasses and were restored toSchema.TaggedError(matching Represent domain failures as Schema.TaggedError #34/base already), andDedupeKeyErrorgained amessagegetter plus aDispatchCollision.errorfield so the collision reason is readable from the event log, not just the typed fields.data/src/factory/.factory/workspaces/.../.tanstack-projected-...) that had leaked into an earlier commit, and switchedscheduler.test.tsfrom a hand-rolled fakeClock.Clockto Effect's realTestClock.Notes for reviewers
startTrackedRun(src/server/runs.ts),registry.get(runId),dedupeRegistry.claim(...),admitRun(...), andregistry.reserve(runId)are all plain synchronous calls on plainMap-backed objects, with the firstawait(allocateWorkspace) only reached afterward, inside thetry. No microtask boundary was introduced between check and set — this held for the pre-refactor module-level version and still holds now that the sameMaps live behindservices.registry/services.dedupeRegistryinstead of at module scope, and still holds at this PR's current head (src/server/runs.tswas untouched by the review follow-ups). Verified by reading the resulting code path, not just the diff hunks.TestClock:scheduler.test.tsnow useseffect/testing's realTestClockinstead of a hand-rolled fake.tickOnce/createSchedulerStatestay deliberately plain async (ADR 0009 §5) and only ever read the clock synchronously viacurrentTimeMillisUnsafe()— they never suspend onEffect.sleep, so none ofTestClock's fiber-coordination machinery (scheduled sleeps, the hung-test warning fiber) actually gets exercised. But building the clock once per fixture viaEffect.runPromise(Effect.scoped(TestClock.make()))and bridgingsetTimeback into the plain-async fixture (the same patternManagedRuntimeuses for bridging Effect services into imperative code) composes cleanly with no contortion oftickOnceor production code, so there was no reason to keep the fake.data/src/factory/.factory/workspaces/run-69780571-.../.tanstack-projected-1e95f9272dfe038f, leaked in2f97451, same failure mode refactor(runtime): move headless permission setup into the agent adapter #55 hit) has been removed viagit rm;.gitignorealready covereddata/and.tanstack-projected-*, and no other straydata/or.factory/workspaces/paths are tracked on this branch.fix: add port guard in two-daemon test for typecheckis a real narrowing check (port1/port2can beundefinedperBun.serve's type), not a weakened assertion.Verification
src/server/two-daemons.test.ts(two-daemon isolation, the acceptance criterion's own proof).concurrency.test.ts), dedupe (dedupe.test.ts,server/dedupe.test.ts), and nested-run (nested-runs.test.ts) tests were updated to threadDaemonServicesthrough but keep the same assertions/intent, including the M1 "reserved before any await" race test inruns.test.ts, which now holds the window open via realallocateWorkspacetiming instead of the removedbeforeStart?hook.bun run check(format:check + lint + typecheck +bun test) run locally at this PR's head: 311 pass, 0 fail, typecheck clean, lint clean (pre-existingeffecttsgostyle warnings only, no errors).Stack
Stack created with GitHub Stacks CLI • Give Feedback 💬