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.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:
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();
return { identity, sunset, idle_out_candidate, ... };
}
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
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
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.mjs—process.argv[2]+console.log(JSON.stringify(...))+process.exit(0/1)ai/scripts/resumeHarness.mjs— same shapeai/scripts/checkAllAgentIdle.mjs— same shapeai/scripts/idleOutNudge.mjs— same shapeai/scripts/trioWakeCooldown.mjs— same shapeEach script's
main()body is the load-bearing logic, but it's coupled toprocess.argvparsing + stdout-JSON emission +process.exitcodes. Importing them directly into SwarmHeartbeatService would re-trigger heavyLifecycleService.initAsync+GraphService.initAsyncside-effects on every cycle, since the scripts are not factored into pure functions.The Problem
SwarmHeartbeatService spawns 5+ Node subprocesses per pulse cycle (
checkSunsetted, optionallyresumeHarness/idleOutNudge,checkAllAgentIdle, optionallytrioWakeCooldown). 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.mjsprecedent. 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])withawait 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
checkSunsetted.mjsexports acheckSunsetted(identity)async function returning the JSON object; existing CLImain()wrapper preserved (calls the exported function + handles stdout-JSON + exit-codes).resumeHarness.mjs,checkAllAgentIdle.mjs,idleOutNudge.mjs,trioWakeCooldown.mjsget the same dual-mode treatment.SwarmHeartbeatService.mjsupdatespulse()to call the exported functions directly instead ofrunScript / runScriptJsonfor these 5 scripts. TherunScript/runScriptJson/runCmdhelpers are preserved forgh issue listandtmux send-keys(still legitimate subprocess invocations for non-Node tools).swarm-heartbeat.sh, manual operator invocation, any other CLI callers) still work unchanged — the dual-mode preserves backward compatibility.checkSunsetted.spec.mjs, etc.) pass without changes; new unit-test covers the exported-function path independently of the CLI wrapper.SwarmHeartbeatService.spec.mjsadds 1-2 tests asserting the direct-import path replaces the prior subprocess seam (e.g.runScriptJsonno longer invoked for the 5 converted scripts).Out of Scope
swarm-heartbeat.shto consume the new export shape — the bash wrapper remains the developer-interactive path; subprocess invocation from bash is the right idiom there.services.mjs-level export for these scripts — they live inai/scripts/, notai/daemons/services/; pulling them into the services manifest is a separate ticket if appropriate.Related
ai/daemons/SwarmHeartbeatService.mjs:57-61references the scripts' subprocess shape + the dual-mode follow-up.ai/daemons/DreamService.mjs(composes services via direct imports)Origin Session ID: 23b9cbcd-4938-4a46-b21a-0d48dd12e7e7