Context
Follow-up debt surfaced during PR #11054 (#11049 Neo bootstrap hygiene). ai/daemons/SwarmHeartbeatService.mjs is currently a hybrid file — it defines the SwarmHeartbeatService class AND has a self-invoke entry-point block at lines 522-540 (const isMain = ...; if (isMain) { Neo.ai.daemons.SwarmHeartbeatService.start(...) }) under launchd / systemd.
This shape diverges from the canonical Orchestrator pattern established by #11009:
ai/daemons/Orchestrator.mjs — pure class (no Neo imports per #11049 invariant)
ai/scripts/orchestrator-daemon.mjs — entry-point wrapper (Neo + core/_export + InstanceManager bootstrap)
Duplicate sweep before filing:
gh issue list --search "SwarmHeartbeatService split entry-point in:title,body" — no matches
gh issue list --search "swarm-heartbeat-daemon entry in:title,body" — no equivalent split ticket
PR #11054 body explicitly captured this as deferred:
"Future cleanup: split into ai/daemons/SwarmHeartbeatService.mjs (class only, no Neo imports) + ai/scripts/swarm-heartbeat-daemon.mjs (entry point); matches Orchestrator class+wrapper pattern."
The Problem
The hybrid file shape forces SwarmHeartbeatService.mjs to import Neo + core/_export at the top (because the self-invoke block at the bottom needs them) AND keep the class definition in the same file. This creates two violations of the entry-point-only invariant:
Class file imports Neo — violates the entry-point-only invariant per #11049 (every other class file in ai/daemons/ correctly omits Neo imports; only SwarmHeartbeatService.mjs is the exception, due to the hybrid shape).
Test specs cannot easily exercise the class without booting the entry point — the self-invoke block fires at module-load if isMain matches, complicating test isolation.
Maintaining the hybrid shape means future contributors continually have to special-case SwarmHeartbeatService.mjs in audits ("class file or entry point? both!").
The Architectural Reality
Sibling precedents in ai/daemons/:
ai/daemons/Orchestrator.mjs — pure class (since #11041 + #11044 + #11049 cleanup)
ai/daemons/DreamService.mjs — pure class (no Neo imports)
ai/daemons/services/TaskStateService.mjs — pure class
ai/daemons/services/ProcessSupervisorService.mjs — pure class
ai/daemons/services/SummarizationCoordinatorService.mjs — pure class
Sibling entry-point wrappers in ai/scripts/:
ai/scripts/orchestrator-daemon.mjs — boots Orchestrator with PID file + lifecycle traps
ai/scripts/bridge-daemon.mjs — boots wake-substrate daemon
- (proposed)
ai/scripts/swarm-heartbeat-daemon.mjs — would boot SwarmHeartbeatService
LaunchAgent / systemd registrations currently target node ai/daemons/SwarmHeartbeatService.mjs. Post-split, they would target node ai/scripts/swarm-heartbeat-daemon.mjs.
The Fix
Step 1: Extract the entry-point wrapper into ai/scripts/swarm-heartbeat-daemon.mjs:
import Neo from '../../src/Neo.mjs';
import * as core from '../../src/core/_export.mjs';
import InstanceManager from '../../src/manager/Instance.mjs';
import logger from '../mcp/server/memory-core/logger.mjs';
import SwarmHeartbeatService from '../daemons/SwarmHeartbeatService.mjs';
const cleanShutdown = signal => {
logger.info(`[SwarmHeartbeatService] Received ${signal}; stopping.`);
SwarmHeartbeatService.stop();
process.exit(0);
};
process.on('SIGTERM', () => cleanShutdown('SIGTERM'));
process.on('SIGINT', () => cleanShutdown('SIGINT'));
SwarmHeartbeatService.start().catch(err => {
logger.error('[SwarmHeartbeatService] Daemon start failed:', err);
process.exit(1);
});
Step 2: Strip Neo + core + InstanceManager imports + self-invoke block from ai/daemons/SwarmHeartbeatService.mjs:
- import Neo from '../../src/Neo.mjs';
- import * as core from '../../src/core/_export.mjs';
- import InstanceManager from '../../src/manager/Instance.mjs';
- ...
- // Self-invoke entrypoint
- const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
- if (isMain) {
- ...
- Neo.ai.daemons.SwarmHeartbeatService.start().catch(...)
- }
Step 3: Update launchd / systemd registration documentation to point at ai/scripts/swarm-heartbeat-daemon.mjs.
Step 4: Verify operator-runnable invocation path — node ai/scripts/swarm-heartbeat-daemon.mjs should drive the daemon identical-equivalent to the prior hybrid shape.
Acceptance Criteria
Out of Scope
- Wake-substrate subprocess-to-direct-import migration — covered separately by #10795 (CLI-shape scripts → dual-mode CLI + module-export so SwarmHeartbeatService imports replace subprocess hops). This ticket is purely a class+wrapper split, NOT a subprocess migration.
- Heartbeat-pulse logic refactor — preserves existing behavior; class definition body unchanged.
- Bash
swarm-heartbeat.sh retirement — that bash script remains for the developer-interactive case per AC10 of #10789; this ticket does not touch it.
Avoided Traps
- ❌ Bundle with broader daemon refactor — keep scope narrow per
feedback_substrate_scope_restraint. This is the smallest coherent unit that closes the entry-point-only invariant gap surfaced by #11049.
- ❌ Move ALL the logic into the wrapper — class-side logic stays in the class file (poll loop, pulse(), getUnreadCount, etc.). The wrapper ONLY owns boot + signal handling + start() invocation, mirroring
orchestrator-daemon.mjs shape.
- ❌ Forget launchd/systemd registration — the path change matters; operator launchd plists would need updating to point at the new wrapper script.
Provenance
- Operator clarification 2026-05-09: "is there an executable file or not" (entry-point criterion)
- PR #11054 body explicit deferral note (commitMessage
refactor(ai): unify v13 daemon entry-point Neo bootstrap (#11049))
- #11041 + #11044 + #11049 established the canonical class+wrapper pattern via Orchestrator
- Empirical anchor:
ai/daemons/SwarmHeartbeatService.mjs:522-540 is the self-invoke block to extract
Related
- PR #11054 (origin of deferred surface)
- #11049 (parent — Neo bootstrap hygiene)
- #11041 (TaskStateService extraction precedent)
- #11044 (ProcessSupervisorService extraction precedent)
- #10789 (SwarmHeartbeatService creation; AC4 self-invoke pattern)
- #10795 (sibling: subprocess-to-direct-import migration)
Self-Identification: @neo-opus-4-7 (Claude Opus 4.7, Claude Code) — chief-architect lane, post-Round-3 daemon-architecture cleanup follow-up. Open lane for self-selection.
Origin Session ID: c2912891-b459-4a03-b2af-154d5e264df1
Context
Follow-up debt surfaced during PR #11054 (#11049 Neo bootstrap hygiene).
ai/daemons/SwarmHeartbeatService.mjsis currently a hybrid file — it defines the SwarmHeartbeatService class AND has a self-invoke entry-point block at lines 522-540 (const isMain = ...; if (isMain) { Neo.ai.daemons.SwarmHeartbeatService.start(...) }) under launchd / systemd.This shape diverges from the canonical
Orchestratorpattern established by #11009:ai/daemons/Orchestrator.mjs— pure class (no Neo imports per #11049 invariant)ai/scripts/orchestrator-daemon.mjs— entry-point wrapper (Neo + core/_export + InstanceManager bootstrap)Duplicate sweep before filing:
gh issue list --search "SwarmHeartbeatService split entry-point in:title,body"— no matchesgh issue list --search "swarm-heartbeat-daemon entry in:title,body"— no equivalent split ticketPR #11054 body explicitly captured this as deferred:
The Problem
The hybrid file shape forces SwarmHeartbeatService.mjs to import Neo + core/_export at the top (because the self-invoke block at the bottom needs them) AND keep the class definition in the same file. This creates two violations of the entry-point-only invariant:
Class file imports Neo — violates the entry-point-only invariant per #11049 (every other class file in
ai/daemons/correctly omits Neo imports; only SwarmHeartbeatService.mjs is the exception, due to the hybrid shape).Test specs cannot easily exercise the class without booting the entry point — the self-invoke block fires at module-load if
isMainmatches, complicating test isolation.Maintaining the hybrid shape means future contributors continually have to special-case SwarmHeartbeatService.mjs in audits ("class file or entry point? both!").
The Architectural Reality
Sibling precedents in
ai/daemons/:ai/daemons/Orchestrator.mjs— pure class (since #11041 + #11044 + #11049 cleanup)ai/daemons/DreamService.mjs— pure class (no Neo imports)ai/daemons/services/TaskStateService.mjs— pure classai/daemons/services/ProcessSupervisorService.mjs— pure classai/daemons/services/SummarizationCoordinatorService.mjs— pure classSibling entry-point wrappers in
ai/scripts/:ai/scripts/orchestrator-daemon.mjs— bootsOrchestratorwith PID file + lifecycle trapsai/scripts/bridge-daemon.mjs— boots wake-substrate daemonai/scripts/swarm-heartbeat-daemon.mjs— would bootSwarmHeartbeatServiceLaunchAgent / systemd registrations currently target
node ai/daemons/SwarmHeartbeatService.mjs. Post-split, they would targetnode ai/scripts/swarm-heartbeat-daemon.mjs.The Fix
Step 1: Extract the entry-point wrapper into
ai/scripts/swarm-heartbeat-daemon.mjs:// Neo namespace bootstrap (entry-point invariant) import Neo from '../../src/Neo.mjs'; import * as core from '../../src/core/_export.mjs'; import InstanceManager from '../../src/manager/Instance.mjs'; import logger from '../mcp/server/memory-core/logger.mjs'; import SwarmHeartbeatService from '../daemons/SwarmHeartbeatService.mjs'; const cleanShutdown = signal => { logger.info(`[SwarmHeartbeatService] Received ${signal}; stopping.`); SwarmHeartbeatService.stop(); process.exit(0); }; process.on('SIGTERM', () => cleanShutdown('SIGTERM')); process.on('SIGINT', () => cleanShutdown('SIGINT')); SwarmHeartbeatService.start().catch(err => { logger.error('[SwarmHeartbeatService] Daemon start failed:', err); process.exit(1); });Step 2: Strip Neo + core + InstanceManager imports + self-invoke block from
ai/daemons/SwarmHeartbeatService.mjs:- import Neo from '../../src/Neo.mjs'; - import * as core from '../../src/core/_export.mjs'; - import InstanceManager from '../../src/manager/Instance.mjs'; - ... - // Self-invoke entrypoint - const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); - if (isMain) { - ... - Neo.ai.daemons.SwarmHeartbeatService.start().catch(...) - }Step 3: Update launchd / systemd registration documentation to point at
ai/scripts/swarm-heartbeat-daemon.mjs.Step 4: Verify operator-runnable invocation path —
node ai/scripts/swarm-heartbeat-daemon.mjsshould drive the daemon identical-equivalent to the prior hybrid shape.Acceptance Criteria
ai/daemons/SwarmHeartbeatService.mjsno longer imports Neo / core/_export / InstanceManager (matchesOrchestrator.mjscleanup invariant)ai/daemons/SwarmHeartbeatService.mjshas noif (isMain)self-invoke block (class-only file)ai/scripts/swarm-heartbeat-daemon.mjsexists with Neo + core/_export + InstanceManager bootstrap + SIGTERM/SIGINT handlers +SwarmHeartbeatService.start()invocationSwarmHeartbeatService.spec.mjstests pass without modification (the spec already imports Neo + core directly per #11049 test-spec-as-entry-point pattern)swarm-heartbeat.spec.mjs(if present and exercises the entry-point) updated to point at new pathpull-request §6.1Out of Scope
swarm-heartbeat.shretirement — that bash script remains for the developer-interactive case per AC10 of #10789; this ticket does not touch it.Avoided Traps
feedback_substrate_scope_restraint. This is the smallest coherent unit that closes the entry-point-only invariant gap surfaced by #11049.orchestrator-daemon.mjsshape.Provenance
refactor(ai): unify v13 daemon entry-point Neo bootstrap (#11049))ai/daemons/SwarmHeartbeatService.mjs:522-540is the self-invoke block to extractRelated
Self-Identification: @neo-opus-4-7 (Claude Opus 4.7, Claude Code) — chief-architect lane, post-Round-3 daemon-architecture cleanup follow-up. Open lane for self-selection.
Origin Session ID: c2912891-b459-4a03-b2af-154d5e264df1