Parent
#32
What to build
The daemon's remaining module-level mutable singletons move into layers, and the optional
test-seam fields they forced come out.
Four maps currently live at module scope: server/runs.ts's active run registry,
server/pubsub.ts's subscribers, lib/dedupe.ts's exported dedupeRegistry, and
lib/workspace.ts's refreshGates. Because they are process-wide, two daemons cannot coexist in
one process and tests share state across files.
The cost is already being paid in instalments, as optional constructor fields that exist purely
because there is no context to read from: dedupeRegistry? on both StartTrackedRunOptions and
DispatchEnv, registry? and now?: () => number on SchedulerDeps, and beforeStart?: () => Promise<void> on StartTrackedRunOptions — a hook whose only job is holding the
reserved-but-not-started window open in one test. Every feature since #13 has added another. This
ticket ends that drip.
The clock seam is worth calling out: Effect ships Clock and a TestClock, so now?: () => number should not survive as a bespoke field.
What must not change is the concurrency reasoning. startTrackedRun reserves its registry slot and
claims its dedupe key synchronously, check-then-set with no await in between, so two
near-simultaneous starts cannot both slip past (D24/D29, #15). Moving the state behind a service
must preserve that atomicity exactly — this is the one place where a careless refactor introduces a
real race.
See docs/adr/0009-imperative-workflows-effect-boundary.md §5.
Acceptance criteria
Blocked by
Parent
#32
What to build
The daemon's remaining module-level mutable singletons move into layers, and the optional
test-seam fields they forced come out.
Four maps currently live at module scope:
server/runs.ts'sactiverun registry,server/pubsub.ts'ssubscribers,lib/dedupe.ts's exporteddedupeRegistry, andlib/workspace.ts'srefreshGates. Because they are process-wide, two daemons cannot coexist inone process and tests share state across files.
The cost is already being paid in instalments, as optional constructor fields that exist purely
because there is no context to read from:
dedupeRegistry?on bothStartTrackedRunOptionsandDispatchEnv,registry?andnow?: () => numberonSchedulerDeps, andbeforeStart?: () => Promise<void>onStartTrackedRunOptions— a hook whose only job is holding thereserved-but-not-started window open in one test. Every feature since #13 has added another. This
ticket ends that drip.
The clock seam is worth calling out: Effect ships
Clockand aTestClock, sonow?: () => numbershould not survive as a bespoke field.What must not change is the concurrency reasoning.
startTrackedRunreserves its registry slot andclaims its dedupe key synchronously, check-then-set with no
awaitin between, so twonear-simultaneous starts cannot both slip past (D24/D29, #15). Moving the state behind a service
must preserve that atomicity exactly — this is the one place where a careless refactor introduces a
real race.
See
docs/adr/0009-imperative-workflows-effect-boundary.md§5.Acceptance criteria
dedupeRegistry?,registry?,now?andbeforeStart?are gone as optional test-seam fields; tests provide layers insteadClock; scheduler tests useTestClockrather than an injectednowawaitbetween check and setbun run checkpassesBlocked by