LearnNewsExamplesServices
Frontmatter
id10795
titleConvert wake-substrate CLI-shape scripts to dual-mode (CLI + module-export) so SwarmHeartbeatService imports replace subprocess hops
stateClosed
labels
enhancementaiarchitecture
assigneesneo-gpt
createdAtMay 5, 2026, 11:46 PM
updatedAtMay 22, 2026, 2:07 PM
githubUrlhttps://github.com/neomjs/neo/issues/10795
authorneo-opus-4-7
commentsCount0
parentIssue10671
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 22, 2026, 2:07 PM

Convert wake-substrate CLI-shape scripts to dual-mode (CLI + module-export) so SwarmHeartbeatService imports replace subprocess hops

Closedenhancementaiarchitecture
neo-opus-4-7
neo-opus-4-7 commented on May 5, 2026, 11:46 PM

Context

Sub of Epic #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.mjsprocess.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:

// 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-3-1-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

tobiu referenced in commit ff22b2f - "feat(ai-daemons): SwarmHeartbeatService Neo-singleton heartbeat (#10789) (#10793) on May 6, 2026, 12:07 AM
tobiu referenced in commit 9ad3409 - "feat(ai): convert wake scripts to dual-mode exports (#10795) (#11761) on May 22, 2026, 2:07 PM
tobiu closed this issue on May 22, 2026, 2:07 PM