---
id: 10795
title: Convert wake-substrate CLI-shape scripts to dual-mode (CLI + module-export) so SwarmHeartbeatService imports replace subprocess hops
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
assignees:
  - neo-gpt
createdAt: '2026-05-05T21:46:53Z'
updatedAt: '2026-06-07T17:24:50Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10795'
author: neo-opus-ada
commentsCount: 0
parentIssue: 10671
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-22T12:07:11Z'
---
# Convert wake-substrate CLI-shape scripts to dual-mode (CLI + module-export) so SwarmHeartbeatService imports replace subprocess hops

## Context

Sub of [Epic #10671](https://github.com/neomjs/neo/issues/10671) (substrate-restart recovery). Sibling-fileable from PR #10793 (#10789 SwarmHeartbeatService Neo-singleton implementation).

Per #10789 AC3, the SwarmHeartbeatService Neo-singleton replaces subprocess invocations with direct module imports "where feasible." For v1, only the genuinely-exported APIs (`MailboxService.sweepExpiredTasks`, `wakeSafetyGate.{isGateOpen,readGateState}`, `heartbeatLock.{inspectHeartbeatLock,releaseHeartbeatLock}`) were converted. The remaining recovery-substrate scripts kept their subprocess invocation shape because they are CLI-only:

- `ai/scripts/checkSunsetted.mjs` — `process.argv[2]` + `console.log(JSON.stringify(...))` + `process.exit(0/1)`
- `ai/scripts/resumeHarness.mjs` — same shape
- `ai/scripts/checkAllAgentIdle.mjs` — same shape
- `ai/scripts/idleOutNudge.mjs` — same shape
- `ai/scripts/trioWakeCooldown.mjs` — same shape

Each script's `main()` body is the load-bearing logic, but it's coupled to `process.argv` parsing + stdout-JSON emission + `process.exit` codes. Importing them directly into SwarmHeartbeatService would re-trigger heavy `LifecycleService.initAsync` + `GraphService.initAsync` side-effects on every cycle, since the scripts are not factored into pure functions.

## The Problem

SwarmHeartbeatService spawns 5+ Node subprocesses per pulse cycle (`checkSunsetted`, optionally `resumeHarness` / `idleOutNudge`, `checkAllAgentIdle`, optionally `trioWakeCooldown`). Each subprocess incurs ~2-5s of Node startup + module-load + service-init. Total subprocess overhead per cycle: ~10s when all paths fire.

For 5-minute polling cadence, this is ~3% CPU on the heartbeat lane — operationally tolerable. But for the long-term direction (#10671 epic-finish + future per-second resolution heartbeat work), the subprocess hop is wasteful.

The architectural motivation: a Neo-singleton daemon should compose its dependencies via in-process module imports, matching `DreamService.mjs` precedent. Subprocess fanout is a code smell that hides shared-state bugs (each subprocess re-inits SQLite + reads state independently; if one mutates state mid-cycle, downstream subprocesses see a different view).

## The Fix

For each script, refactor to dual-mode shape: expose the load-bearing logic as a named exported function while preserving the existing CLI-shape `main()` wrapper:

```javascript
// ai/scripts/checkSunsetted.mjs
import Neo from '../../src/Neo.mjs';
import * as core from '../../src/core/_export.mjs';
import LifecycleService from '../mcp/server/memory-core/services/lifecycle/SystemLifecycleService.mjs';
import GraphService from '../mcp/server/memory-core/services/GraphService.mjs';
import { checkInflightLock } from './inflightLock.mjs';

export async function checkSunsetted(identity) {
    await LifecycleService.initAsync();
    await GraphService.initAsync();
    // ... existing main() body, returning the JSON object
    return { identity, sunset, idle_out_candidate, ... };
}

// CLI wrapper preserved for shell-side consumers (the existing swarm-heartbeat.sh)
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isMain) {
    const identity = process.argv[2] || process.env.NEO_AGENT_IDENTITY || '@neo-gemini-pro';
    checkSunsetted(identity).then(result => {
        console.log(JSON.stringify(result));
        process.exit(0);
    }).catch(err => {
        console.error('checkSunsetted failed:', err.message);
        process.exit(1);
    });
}
```

Then SwarmHeartbeatService can replace `await this.runScriptJson('checkSunsetted.mjs', [identity])` with `await checkSunsetted(this.identity)`.

Apply the same pattern to all 5 scripts. Bonus: SwarmHeartbeatService's test-stubbable seam pattern (instance-method wrappers around module-binding imports) becomes more useful once the underlying module exports.

## Acceptance Criteria

- [ ] **(AC1)** `checkSunsetted.mjs` exports a `checkSunsetted(identity)` async function returning the JSON object; existing CLI `main()` wrapper preserved (calls the exported function + handles stdout-JSON + exit-codes).
- [ ] **(AC2)** `resumeHarness.mjs`, `checkAllAgentIdle.mjs`, `idleOutNudge.mjs`, `trioWakeCooldown.mjs` get the same dual-mode treatment.
- [ ] **(AC3)** `SwarmHeartbeatService.mjs` updates `pulse()` to call the exported functions directly instead of `runScript / runScriptJson` for these 5 scripts. The `runScript` / `runScriptJson` / `runCmd` helpers are preserved for `gh issue list` and `tmux send-keys` (still legitimate subprocess invocations for non-Node tools).
- [ ] **(AC4)** Existing CLI consumers (`swarm-heartbeat.sh`, manual operator invocation, any other CLI callers) still work unchanged — the dual-mode preserves backward compatibility.
- [ ] **(AC5)** Existing per-script unit specs (`checkSunsetted.spec.mjs`, etc.) pass without changes; new unit-test covers the exported-function path independently of the CLI wrapper.
- [ ] **(AC6)** `SwarmHeartbeatService.spec.mjs` adds 1-2 tests asserting the direct-import path replaces the prior subprocess seam (e.g. `runScriptJson` no longer invoked for the 5 converted scripts).

## Out of Scope

- Refactoring `swarm-heartbeat.sh` to consume the new export shape — the bash wrapper remains the developer-interactive path; subprocess invocation from bash is the right idiom there.
- Adding a `services.mjs`-level export for these scripts — they live in `ai/scripts/`, not `ai/daemons/services/`; pulling them into the services manifest is a separate ticket if appropriate.
- Performance benchmarking the before/after — operationally the difference is sub-second per cycle and not load-bearing for night-shift readiness.

## Related

- **Parent epic:** #10671 (substrate-restart recovery)
- **Spawning PR:** #10793 (#10789 SwarmHeartbeatService Neo-singleton); GPT review Cycle 1 P3 item flagged the stale #10790 reference as needing this ticket as the actual referent.
- **Sibling tickets:** #10789 (impl), #10788 (Claude Desktop Tab 3 target validation)
- **Source comment to update:** `ai/daemons/SwarmHeartbeatService.mjs:57-61` references the scripts' subprocess shape + the dual-mode follow-up.
- **Canonical Neo-class precedent:** `ai/daemons/DreamService.mjs` (composes services via direct imports)

Origin Session ID: 23b9cbcd-4938-4a46-b21a-0d48dd12e7e7

## Timeline

- 2026-05-05T21:46:55Z @neo-opus-ada added the `enhancement` label
- 2026-05-05T21:46:55Z @neo-opus-ada added the `ai` label
- 2026-05-05T21:46:55Z @neo-opus-ada added the `architecture` label
- 2026-05-05T21:48:00Z @neo-opus-ada added parent issue #10671
- 2026-05-05T21:50:10Z @neo-opus-ada referenced in commit `d957178` - "fix(ai-daemons): address PR #10793 cycle-1 review (#10789)

Cycle-1 Required Actions from @neo-gpt's Request Changes (per
pr-review-guide §5):

- Add @reactive JSDoc tags to pollHandle_, identity_, pollIntervalMs_
  (CODING_GUIDELINES.md trailing-underscore rule).
- Manual one-shot recipe in PersistentProcessManagement.md §2 now
  uses kill -TERM (matches the SIGTERM handler), not perl alarm
  (which sends SIGALRM, bypassing cleanShutdown).
- §3d lock-mtime polling claim replaced with the actual log-line
  evidence shape (startup line + activity-conditional INFO lines);
  added explicit anti-pattern callout that the heartbeat lock is
  producer-side state, not pulse-side observability.
- Stale #10790 source reference replaced with the actual follow-up
  ticket #10795 (filed today; tracks dual-mode CLI+module-export
  refactor of remaining recovery scripts).

Co-authored-by: Neo Claude Opus <neo-opus-4-7@neomjs.com>"
- 2026-05-05T21:50:56Z @neo-opus-ada cross-referenced by PR #10793
- 2026-05-05T21:55:39Z @neo-opus-ada referenced in commit `4ebc2ea` - "fix(ai-daemons): replace remaining #10790 ref with #10795 (#10789)

Cycle 2 follow-up from @neo-gpt: missed a second #10790 reference at
SwarmHeartbeatService.mjs:430-433 (runScript JSDoc). The class-level
prose at lines 57-61 was updated in cycle 2; this catches the inline
ref I missed.

Co-authored-by: Neo Claude Opus <neo-opus-4-7@neomjs.com>"
- 2026-05-05T22:07:58Z @tobiu referenced in commit `ff22b2f` - "feat(ai-daemons): SwarmHeartbeatService Neo-singleton heartbeat (#10789) (#10793)

* feat(ai-daemons): SwarmHeartbeatService Neo-singleton heartbeat (#10789)

Neo-singleton replacement for the bash-shape persistent-process attempt
that was closed not-planned via PR #10782. Sibling `swarm-heartbeat.sh`
preserved per AC10 for developer-interactive use; this singleton is the
launchd / systemd target for night-shift readiness.

Where direct module imports replace subprocess invocations (#10789 AC3,
"where feasible"): MailboxService.sweepExpiredTasks, wakeSafetyGate
exports, heartbeatLock primitives. CLI-shape recovery scripts
(checkSunsetted / resumeHarness / checkAllAgentIdle / idleOutNudge /
trioWakeCooldown) preserved as subprocess invocations; #10790-class
follow-up extracts them to dual-mode (CLI + module-export).

Includes launchd plist template (AC7), operator doc (AC8) with verify
procedures + troubleshooting matrix, and 14-test unit spec (AC6) using
test-stubbable instance-method seams over module-binding imports.

Co-authored-by: Neo Claude Opus <neo-opus-4-7@neomjs.com>

* fix(ai-daemons): address PR #10793 cycle-1 review (#10789)

Cycle-1 Required Actions from @neo-gpt's Request Changes (per
pr-review-guide §5):

- Add @reactive JSDoc tags to pollHandle_, identity_, pollIntervalMs_
  (CODING_GUIDELINES.md trailing-underscore rule).
- Manual one-shot recipe in PersistentProcessManagement.md §2 now
  uses kill -TERM (matches the SIGTERM handler), not perl alarm
  (which sends SIGALRM, bypassing cleanShutdown).
- §3d lock-mtime polling claim replaced with the actual log-line
  evidence shape (startup line + activity-conditional INFO lines);
  added explicit anti-pattern callout that the heartbeat lock is
  producer-side state, not pulse-side observability.
- Stale #10790 source reference replaced with the actual follow-up
  ticket #10795 (filed today; tracks dual-mode CLI+module-export
  refactor of remaining recovery scripts).

Co-authored-by: Neo Claude Opus <neo-opus-4-7@neomjs.com>

* fix(ai-daemons): replace remaining #10790 ref with #10795 (#10789)

Cycle 2 follow-up from @neo-gpt: missed a second #10790 reference at
SwarmHeartbeatService.mjs:430-433 (runScript JSDoc). The class-level
prose at lines 57-61 was updated in cycle 2; this catches the inline
ref I missed.

Co-authored-by: Neo Claude Opus <neo-opus-4-7@neomjs.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-09T20:49:32Z @neo-opus-ada cross-referenced by #11058
- 2026-05-09T22:54:44Z @neo-opus-ada cross-referenced by #11073
- 2026-05-09T22:55:14Z @neo-opus-ada cross-referenced by #11074
- 2026-05-22T11:03:46Z @neo-gpt assigned to @neo-gpt
- 2026-05-22T11:16:51Z @neo-gpt cross-referenced by PR #11761
- 2026-05-22T11:38:43Z @neo-gpt cross-referenced by PR #11762
- 2026-05-22T12:07:11Z @tobiu referenced in commit `9ad3409` - "feat(ai): convert wake scripts to dual-mode exports (#10795) (#11761)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-22T12:07:11Z @tobiu closed this issue
- 2026-05-22T17:57:45Z @neo-gpt cross-referenced by #11775
- 2026-06-05T17:08:29Z @neo-opus-ada cross-referenced by #10671

