---
id: 11058
title: Split SwarmHeartbeatService into class file + ai/scripts/ entry-point wrapper (Orchestrator pattern)
state: CLOSED
labels:
  - enhancement
  - ai
  - refactoring
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-09T20:49:31Z'
updatedAt: '2026-06-07T17:09:22Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11058'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-09T21:33:43Z'
---
# Split SwarmHeartbeatService into class file + ai/scripts/ entry-point wrapper (Orchestrator pattern)

## 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`:**

```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`:**

```diff
- 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

- [ ] 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-ada (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

## Timeline

- 2026-05-09T20:49:33Z @neo-opus-ada added the `enhancement` label
- 2026-05-09T20:49:33Z @neo-opus-ada added the `ai` label
- 2026-05-09T20:49:33Z @neo-opus-ada added the `refactoring` label
- 2026-05-09T20:49:33Z @neo-opus-ada added the `architecture` label
- 2026-05-09T21:10:31Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-09T21:16:07Z @neo-opus-ada cross-referenced by PR #11061
- 2026-05-09T21:33:43Z @tobiu referenced in commit `2c3f4ae` - "refactor(ai): split SwarmHeartbeatService into class + entry-point wrapper (#11058) (#11061)

Closes the entry-point-only invariant gap surfaced during PR #11054 (#11049):
SwarmHeartbeatService was a hybrid file (class + self-invoke at the bottom),
the only daemon-tier class still importing Neo directly post-#11054. This PR
applies the canonical Orchestrator class+wrapper pattern (#11041 / #11044 /
#11049 precedent):

- `ai/daemons/SwarmHeartbeatService.mjs`: class-only file. Removed Neo +
  core/_export + InstanceManager imports + self-invoke `if (isMain)` block at
  the bottom. Now matches sibling pattern (TaskStateService /
  ProcessSupervisorService / SummarizationCoordinatorService / DreamService).

- `ai/scripts/swarm-heartbeat-daemon.mjs` (NEW): entry-point wrapper. Owns Neo
  namespace bootstrap + SIGTERM / SIGINT clean-stop signal handlers +
  `SwarmHeartbeatService.start()` invocation. Mirrors orchestrator-daemon.mjs
  shape.

- `test/playwright/unit/ai/daemons/SwarmHeartbeatService.spec.mjs`: added
  `import Neo + import * as core` at top to bootstrap the Neo namespace before
  the dynamic SwarmHeartbeatService import. Matches the test-spec-as-entry-point
  pattern from sibling specs (TaskStateService.spec / ProcessSupervisorService.spec
  / SummarizationCoordinatorService.spec / Orchestrator.spec post-#11049).

- `learn/agentos/wake-substrate/PersistentProcessManagement.md`: updated
  invocation paths from `node ai/daemons/SwarmHeartbeatService.mjs` →
  `node ai/scripts/swarm-heartbeat-daemon.mjs` (lines 35, 46, 188 + descriptive
  references). The class file itself is still the daemon source (line 231).

- `learn/agentos/wake-substrate/com.neomjs.swarm-heartbeat.plist.template`:
  updated launchd ProgramArguments path + Target comment to point at the
  wrapper.

Empirical:
- 14/14 SwarmHeartbeatService.spec tests pass
- 52/52 MailboxService.spec tests pass (no collateral damage)
- node --check passes for both class file + new wrapper
- Net diff: -49 / +31 lines (net reduction; substrate accretion defense
  satisfied — the split removes redundant comment-prelude + self-invoke
  block, replacing with smaller wrapper file)

Operator action required post-merge:
- launchd / systemd installations using the old direct-class-file path will
  need to update their plist / .service file to target
  `ai/scripts/swarm-heartbeat-daemon.mjs`. The .plist.template already updated;
  re-running §3a sed substitution against the new template captures it.

Self-Identification: @neo-opus-4-7 (Claude Opus 4.7, Claude Code)
Origin Session ID: c2912891-b459-4a03-b2af-154d5e264df1

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-09T21:33:43Z @tobiu closed this issue
- 2026-05-09T22:00:04Z @neo-opus-ada cross-referenced by #11066
- 2026-05-09T22:07:31Z @neo-opus-ada cross-referenced by PR #11067
- 2026-05-09T22:54:44Z @neo-opus-ada cross-referenced by #11073

