LearnNewsExamplesServices
Frontmatter
id11058
titleSplit SwarmHeartbeatService into class file + ai/scripts/ entry-point wrapper (Orchestrator pattern)
stateClosed
labels
enhancementairefactoringarchitecture
assigneesneo-opus-4-7
createdAtMay 9, 2026, 10:49 PM
updatedAtMay 9, 2026, 11:33 PM
githubUrlhttps://github.com/neomjs/neo/issues/11058
authorneo-opus-4-7
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 9, 2026, 11:33 PM

Split SwarmHeartbeatService into class file + ai/scripts/ entry-point wrapper (Orchestrator pattern)

Closedenhancementairefactoringarchitecture
neo-opus-4-7
neo-opus-4-7 commented on May 9, 2026, 10:49 PM

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:

  1. 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).

  2. 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:

// 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 pathnode ai/scripts/swarm-heartbeat-daemon.mjs should drive the daemon identical-equivalent to the prior hybrid shape.

Acceptance Criteria

  • AC1 — ai/daemons/SwarmHeartbeatService.mjs no longer imports Neo / core/_export / InstanceManager (matches Orchestrator.mjs cleanup invariant)
  • AC2 — ai/daemons/SwarmHeartbeatService.mjs has no if (isMain) self-invoke block (class-only file)
  • AC3 — ai/scripts/swarm-heartbeat-daemon.mjs exists with Neo + core/_export + InstanceManager bootstrap + SIGTERM/SIGINT handlers + SwarmHeartbeatService.start() invocation
  • AC4 — Existing SwarmHeartbeatService.spec.mjs tests pass without modification (the spec already imports Neo + core directly per #11049 test-spec-as-entry-point pattern)
  • AC5 — swarm-heartbeat.spec.mjs (if present and exercises the entry-point) updated to point at new path
  • AC6 — launchd / systemd registration docs updated
  • AC7 — Cross-family review per pull-request §6.1

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

tobiu referenced in commit 2c3f4ae - "refactor(ai): split SwarmHeartbeatService into class + entry-point wrapper (#11058) (#11061) on May 9, 2026, 11:33 PM
tobiu closed this issue on May 9, 2026, 11:33 PM