The 25+-ticket "thin Orchestrator" refactor wave moved behavior into purpose-shaped places (registry pattern at scheduling/registry.mjs, per-lane getDueTask pure functions, singleton services like SwarmHeartbeatService/TaskStateService/ProcessSupervisorService). But the daemon.mjs config router was left behind — it still pre-resolves every lane's interval + enable-toggle + lane-internal config into a flat options bag that Orchestrator.start() discards because the Orchestrator has its own getter pattern that supersedes it.
Each Orchestrator getter does the IDENTICAL env-override + AiConfig.orchestrator.X fallback the daemon resolver does (lines 347-394 pattern: return Env.parseNumber('NEO_ORCHESTRATOR_X') ?? AiConfig.orchestrator.intervals.X;). Orchestrator.start() at Orchestrator.mjs:416-489 consumes ONLY scriptDir, dataDir, dbPath, logFile, stateFile, heavyMaintenanceLeasePath, taskDefinitions, nodeBin, mlxEnabled/mlxModel/mlxPort, lmsEnabled/lmsModel/lmsPort, primaryDevSyncRootsConfig — none of the 12 resolver-produced keys.
The resolver's output is discarded at the start() boundary. ~80 lines of pure router boilerplate (the function itself + assignConfigInterval helper + assignLocalOnlyToggle helper + spread call site at line 352).
Why tests didn't catch this
test/playwright/unit/ai/daemons/orchestrator/daemon.spec.mjs has 8+ test blocks asserting the resolver's output shape. The tests verify what the function returns, not what consumes the return value. Classic tests-as-architectural-anchor failure mode — they pin the dead code in place by giving it visible green coverage.
resolveMlxConfig / resolveLmsConfig — live but architecturally borderline
daemon.mjs:286-326. These ARE consumed: startOrchestrator resolves them → passes mlxEnabled/mlxModel/mlxPort/lmsEnabled/lmsModel/lmsPort to Orchestrator.start({...}) → forwarded to buildTaskDefinitions(...) at Orchestrator.mjs:431-436.
Flow: daemon.mjs (resolve) → Orchestrator.start (pass-through) → buildTaskDefinitions (consume). The mlx/lms env-override + fallback pattern is identical-shape to the interval-getter pattern. Substrate-correct shape: Orchestrator owns the resolution via getters (get mlxEnabled(), get mlxModel(), etc.), and buildTaskDefinitions either reads from this.X or is invoked with the Orchestrator-resolved values.
The Fix
Three sub-changes, all surgical:
Delete resolveOrchestratorStartOptions + assignConfigInterval + assignLocalOnlyToggle from daemon.mjs. Delete the spread ...resolveOrchestratorStartOptions(...) at line 352. Net daemon.mjs: -85 LOC.
Consolidate resolveMlxConfig / resolveLmsConfig into Orchestrator getters at the existing getter cluster. Pattern matches the interval-getter pattern already in place:
The Orchestrator picks up everything else from its own getters reading AiConfig + env directly.
Delete the corresponding daemon.spec.mjs test blocks for the removed resolvers (the tests verify dead code; removing the code retires the tests). Add new Orchestrator getter coverage for mlxEnabled/mlxModel/mlxPort/lmsEnabled/lmsModel/lmsPort mirroring the existing interval-getter test patterns.
AC3 — Orchestrator.start() consumes mlx/lms config via this.X getters (not via options.X); buildTaskDefinitions call updated.
AC4 — daemon.mjs::startOrchestrator collapses to thin boot wrapper (PID, signals, Neo bootstrap, config load, delegate). Only test-injection seams remain in the options signature.
AC5 — daemon.spec.mjs test blocks for the removed resolvers deleted. New Orchestrator getter test coverage for mlx/lms added (mirrors interval-getter test patterns).
AC6 — Live smoke: npm run ai:orchestrator boots cleanly; all lanes (summary, kbSync, backup, primaryDevSync, dream, goldenPath, swarmHeartbeat) resolve their cadence correctly via getters; mlx/lms tasks (when enabled in config) launch correctly.
Lane-internal config ownership beyond mlx/lms (e.g., moving summarySweepIntervalMs INTO scheduling/summary.mjs as a self-reading constant). The Orchestrator-getter pattern is the shipped substrate-correct shape; respecting it is sufficient. Deeper "lanes own their entire config" refactor is a separate substrate-evolution lane if needed.
Process-management primitives (PID file, signal handlers, log file routing). These legitimately belong in daemon.mjs as the process-boot entry point.
loadLocalAiConfig — config substrate bootstrap is correctly a daemon-level concern; runs before the Orchestrator class loads.
Adjacent lanes #11994 (gate-fix on PR #12004) and #12003 (candidate-discovery). Different file surfaces, no overlap.
Avoided Traps
Preserving resolveOrchestratorStartOptions as a "backwards-compat shim" — operator's standing rule from earlier sessions: "no backwards compatibility for these config migrations." The flat-options bag was always discarded; preserving it would extend the dead-code lifetime.
Moving mlx/lms config INTO each task definition's module (deeper than the Orchestrator getter pattern). That'd diverge from the established interval-getter pattern; consistency wins for now.
Keeping the daemon.spec.mjs blocks "for archaeology" — removed-code tests are debt. The removed code's intent is preserved in this ticket body + git history.
Related
25+-ticket "thin Orchestrator" refactor wave that motivated the substrate-evolution direction this ticket completes
#11991 (config class-substrate completion) — established class Config extends BaseConfig pattern that makes AiConfig.orchestrator.X chain reliable for the getters to read
#11986/#11987 (lms server lifecycle as orchestrator task) — established mlx/lms as supervised-process lanes; this ticket consolidates their config to match the rest of the lane-config substrate
tobiu referenced in commit 0c95af0 - "refactor(orchestrator): delete daemon.mjs router dead code; consolidate mlx/lms config into Orchestrator getters (#12005) (#12006) on May 26, 2026, 2:59 AM
The 25+-ticket "thin Orchestrator" refactor wave moved behavior into purpose-shaped places (registry pattern at
scheduling/registry.mjs, per-lanegetDueTaskpure functions, singleton services likeSwarmHeartbeatService/TaskStateService/ProcessSupervisorService). But the daemon.mjs config router was left behind — it still pre-resolves every lane's interval + enable-toggle + lane-internal config into a flat options bag thatOrchestrator.start()discards because the Orchestrator has its own getter pattern that supersedes it.V-B-A — daemon.mjs is doing dead routing
ai/daemons/orchestrator/daemon.mjs::resolveOrchestratorStartOptions(lines 182-268) maps 13 keys into a flat options bag:summarySweepIntervalMsget summarySweepIntervalMs()at Orchestrator.mjs:347kbSyncIntervalMsget kbSyncIntervalMs()at Orchestrator.mjs:348backupIntervalMsget backupIntervalMs()at Orchestrator.mjs:349primaryDevSyncIntervalMsget primaryDevSyncIntervalMs()at Orchestrator.mjs:350dreamIntervalMsget dreamIntervalMs()at Orchestrator.mjs:371goldenPathIntervalMsget goldenPathIntervalMs()at Orchestrator.mjs:372swarmHeartbeatIntervalMsget swarmHeartbeatIntervalMs()at Orchestrator.mjs:373kbSyncEnabledget kbSyncEnabled()at Orchestrator.mjs:389primaryDevSyncEnabledget primaryDevSyncEnabled()at Orchestrator.mjs:390bridgeDaemonEnabledget bridgeDaemonEnabled()at Orchestrator.mjs:392swarmHeartbeatEnabledget swarmHeartbeatEnabled()at Orchestrator.mjs:393goldenPathRepoEnrichmentEnabledget goldenPathRepoEnrichmentEnabled()at Orchestrator.mjs:394Each Orchestrator getter does the IDENTICAL env-override +
AiConfig.orchestrator.Xfallback the daemon resolver does (lines 347-394 pattern:return Env.parseNumber('NEO_ORCHESTRATOR_X') ?? AiConfig.orchestrator.intervals.X;).Orchestrator.start()at Orchestrator.mjs:416-489 consumes ONLYscriptDir, dataDir, dbPath, logFile, stateFile, heavyMaintenanceLeasePath, taskDefinitions, nodeBin, mlxEnabled/mlxModel/mlxPort, lmsEnabled/lmsModel/lmsPort, primaryDevSyncRootsConfig— none of the 12 resolver-produced keys.The resolver's output is discarded at the start() boundary. ~80 lines of pure router boilerplate (the function itself +
assignConfigIntervalhelper +assignLocalOnlyTogglehelper + spread call site at line 352).Why tests didn't catch this
test/playwright/unit/ai/daemons/orchestrator/daemon.spec.mjshas 8+ test blocks asserting the resolver's output shape. The tests verify what the function returns, not what consumes the return value. Classic tests-as-architectural-anchor failure mode — they pin the dead code in place by giving it visible green coverage.resolveMlxConfig/resolveLmsConfig— live but architecturally borderlinedaemon.mjs:286-326. These ARE consumed:startOrchestratorresolves them → passesmlxEnabled/mlxModel/mlxPort/lmsEnabled/lmsModel/lmsPorttoOrchestrator.start({...})→ forwarded tobuildTaskDefinitions(...)at Orchestrator.mjs:431-436.Flow:
daemon.mjs (resolve) → Orchestrator.start (pass-through) → buildTaskDefinitions (consume). The mlx/lms env-override + fallback pattern is identical-shape to the interval-getter pattern. Substrate-correct shape: Orchestrator owns the resolution via getters (get mlxEnabled(),get mlxModel(), etc.), andbuildTaskDefinitionseither reads fromthis.Xor is invoked with the Orchestrator-resolved values.The Fix
Three sub-changes, all surgical:
Delete
resolveOrchestratorStartOptions+assignConfigInterval+assignLocalOnlyTogglefromdaemon.mjs. Delete the spread...resolveOrchestratorStartOptions(...)at line 352. Net daemon.mjs: -85 LOC.Consolidate
resolveMlxConfig/resolveLmsConfiginto Orchestrator getters at the existing getter cluster. Pattern matches the interval-getter pattern already in place:get mlxEnabled() { return Env.parseBool('NEO_ORCHESTRATOR_MLX_ENABLED') ?? !!AiConfig.orchestrator.mlx?.enabled; } get mlxModel() { return process.env.NEO_ORCHESTRATOR_MLX_MODEL || AiConfig.orchestrator.mlx?.model; } get mlxPort() { return process.env.NEO_ORCHESTRATOR_MLX_PORT || AiConfig.orchestrator.mlx?.port; } // ditto for lmsRemove
resolveMlxConfig/resolveLmsConfigfromdaemon.mjs. UpdatebuildTaskDefinitionscall inOrchestrator.start()to readthis.mlxEnabled/this.mlxModel/this.mlxPort/this.lmsEnabled/this.lmsModel/this.lmsPortdirectly. Net daemon.mjs: -50 LOC, Orchestrator.mjs: +6 getters.Collapse
daemon.mjs::startOrchestratorto:export async function startOrchestrator(options = {}) { fs.ensureDirSync(DAEMON_DATA_DIR); await enforceSingleton(); setupCleanupHandlers(); await loadLocalAiConfig(); return Orchestrator.start({ dataDir: DAEMON_DATA_DIR, primaryDevSyncRootsConfig: AiConfig.orchestrator?.devSyncRoots, ...options // test-injection seams only }); }The Orchestrator picks up everything else from its own getters reading
AiConfig+ env directly.Delete the corresponding daemon.spec.mjs test blocks for the removed resolvers (the tests verify dead code; removing the code retires the tests). Add new Orchestrator getter coverage for
mlxEnabled/mlxModel/mlxPort/lmsEnabled/lmsModel/lmsPortmirroring the existing interval-getter test patterns.Contract Ledger
daemon.mjs::startOrchestratorOrchestrator.start({dataDir, primaryDevSyncRootsConfig, ...options})scriptDir,dbPath,taskDefinitions, etc.) still forwarded via spreadOrchestratormlx/lms gettersget mlxEnabled() / mlxModel() / mlxPort() / lmsEnabled() / lmsModel() / lmsPort()— same env+AiConfig pattern as interval gettersAiConfig.orchestrator.mlx?.enabled/.model/.portdefaults (optional-chaining for missing config blocks)Orchestrator.start(options)this.Xgetters inside;buildTaskDefinitionscall usesthis.mlxEnabled/this.mlxModel/...options.mlxEnabled/ etc. removed from documented signature (only test-injection seams remain)daemon.spec.mjsAcceptance Criteria
daemon.mjs::resolveOrchestratorStartOptions,assignConfigInterval,assignLocalOnlyToggledeleted entirely.daemon.mjs::resolveMlxConfig,resolveLmsConfigdeleted;Orchestratorexposes 6 new getters (mlxEnabled,mlxModel,mlxPort,lmsEnabled,lmsModel,lmsPort) mirroring the existing env+AiConfig fallback pattern.Orchestrator.start()consumes mlx/lms config viathis.Xgetters (not viaoptions.X);buildTaskDefinitionscall updated.daemon.mjs::startOrchestratorcollapses to thin boot wrapper (PID, signals, Neo bootstrap, config load, delegate). Only test-injection seams remain in the options signature.daemon.spec.mjstest blocks for the removed resolvers deleted. New Orchestrator getter test coverage for mlx/lms added (mirrors interval-getter test patterns).npm run ai:orchestratorboots cleanly; all lanes (summary, kbSync, backup, primaryDevSync, dream, goldenPath, swarmHeartbeat) resolve their cadence correctly via getters; mlx/lms tasks (when enabled in config) launch correctly.grep -rn "resolveOrchestratorStartOptions\|resolveMlxConfig\|resolveLmsConfig\|assignConfigInterval\|assignLocalOnlyToggle" ai/ test/ --include='*.mjs'returns zero matches.pull-request §6.1.Out of Scope
scheduling/summary.mjsas a self-reading constant). The Orchestrator-getter pattern is the shipped substrate-correct shape; respecting it is sufficient. Deeper "lanes own their entire config" refactor is a separate substrate-evolution lane if needed.loadLocalAiConfig— config substrate bootstrap is correctly a daemon-level concern; runs before the Orchestrator class loads.Avoided Traps
resolveOrchestratorStartOptionsas a "backwards-compat shim" — operator's standing rule from earlier sessions: "no backwards compatibility for these config migrations." The flat-options bag was always discarded; preserving it would extend the dead-code lifetime.Related
class Config extends BaseConfigpattern that makesAiConfig.orchestrator.Xchain reliable for the getters to readHandoff Retrieval Hint: "daemon.mjs router cleanup resolveOrchestratorStartOptions dead code Orchestrator getters mlx lms config consolidation thin Orchestrator completion".