Parent Epic
#11829 — Sub 1 of 5 (BLOCKING; first in suggested implementation sequence).
Scope
Implement resolveSwarmHeartbeatTargets() contract — deployment-strategy resolver that replaces SwarmHeartbeatService's current single-identity binding pattern (currently bound at initAsync to process.env.NEO_AGENT_IDENTITY || DEFAULT_IDENTITY; only one identity checked per pulse). Single Orchestrator lane loops over RESOLVED targets per deployment profile.
Critical: NOT N service-instances; NOT direct iteration over identityRoots.mjs IDENTITIES (which would leak Neo-maintainer-team assumptions into framework-portable substrate).
Empirical anchor
Operator log evidence:
[idleOutNudge] Sent heartbeat nudge to @neo-opus-4-7
No equivalent log line for @neo-gpt. GPT's "lifecycle-driver watchdog" is a custom Codex-side workaround for a canonical substrate gap, not parallel implementation. The canonical orchestrator nudge path is currently family-asymmetric per SwarmHeartbeatService.mjs:102 / :129-131 / :199 / :224 single-identity binding.
Acceptance Criteria
- Resolver precedence chain (per Epic AC2):
- (1) Explicit env/config target list —
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_TARGETS env var (comma-separated) OR orchestrator.swarmHeartbeat.targets config array. Values normalize via existing identity normalization.
- (2) Config strategy —
orchestrator.swarmHeartbeat.targetSource ∈ {self | active-local-team | active-subscribers | disabled}.
- (3) Public default
self — resolved from NEO_AGENT_IDENTITY / bound stdio identity / gh login fallback. If no identity resolves → disable lane with clear config log (NOT silent fallback to Neo maintainer identity).
- (4) Neo team profile (
active-local-team): OPT-IN, uses identityRoots.mjs IDENTITIES filtered on participationStatus === 'active'. Benched identities excluded.
- (5) Cloud / fork safety per ADR 0014: cloud profile heartbeat-disabled by default;
npx neo-app workspaces never receive Neo maintainer wake targets unless explicitly configured.
- Single Orchestrator lane loops over resolved targets — output of
resolveSwarmHeartbeatTargets(). Per-pulse loops at SwarmHeartbeatService.mjs:199 and :224 consume per-target list, not just this.identity.
- Test coverage at
test/playwright/unit/ai/daemons/services/SwarmHeartbeatService.spec.mjs:
- No-config external workspace: heartbeat monitors only current resolved identity OR disables-with-log
- Explicit target list: one pulse cycle calls
checkSunsetted(identity) per configured target, dispatches per-identity recovery independently
- Neo
active-local-team profile: active maintainers included, benched excluded; assertion N active targets → N checkSunsetted calls
- Unknown target: fail-closed with clear resolver error; no orphan/null mailbox edges
- Empirical validation: orchestrator log post-implementation shows N nudge lines per cycle where N =
resolveSwarmHeartbeatTargets() output cardinality. Cross-family parity restored (Opus + GPT both receive nudges per cycle).
- Backward-compat seam: legacy single-identity
initAsync({identity}) path remains for diagnostic mode.
Files (likely touched)
ai/daemons/services/SwarmHeartbeatService.mjs (primary surface — refactor single-identity binding to multi-target resolver)
ai/daemons/services/resolveSwarmHeartbeatTargets.mjs (NEW — pure resolver function; testable in isolation)
ai/config.template.mjs (NEW orchestrator.swarmHeartbeat.{targets, targetSource} config namespace)
test/playwright/unit/ai/daemons/services/SwarmHeartbeatService.spec.mjs (extend coverage)
test/playwright/unit/ai/daemons/services/resolveSwarmHeartbeatTargets.spec.mjs (NEW — resolver unit tests)
Avoided Traps
- Iterating directly over
identityRoots.mjs IDENTITIES (Cycle-1 source-trace draft I proposed; corrected Cycle-2 by GPT). External consumers (repo forks, npx neo-app, cloud) MUST NOT silently inherit Neo maintainer identities.
- Spawning N service-instances (one per identity) — single service-lane + per-target loop is the substrate-correct shape.
- Hardcoding
NEO_AGENT_IDENTITY env-var as the only resolution path — the env var is the self strategy's source, not the universal target source.
Related substrate anchors
- Parent Epic: #11829
- Originating Discussion: #11823 (closed RESOLVED 2026-05-23T10:49:36Z)
- ADR 0014 cloud-deployment-topology profile-default precedent:
learn/agentos/decisions/0014-cloud-deployment-topology-and-scheduler-task-taxonomy.md
ai/scripts/idleOutNudge.mjs (per-identity sender — confirmed correct shape; not the gap)
ai/graph/identityRoots.mjs (Opus + GPT active, Gemini operator_benched per ground truth; active-local-team opt-in source)
Authored by: [Claude Opus 4.7] (Claude Code)
Parent Epic
#11829 — Sub 1 of 5 (BLOCKING; first in suggested implementation sequence).
Scope
Implement
resolveSwarmHeartbeatTargets()contract — deployment-strategy resolver that replacesSwarmHeartbeatService's current single-identity binding pattern (currently bound atinitAsynctoprocess.env.NEO_AGENT_IDENTITY || DEFAULT_IDENTITY; only one identity checked per pulse). Single Orchestrator lane loops over RESOLVED targets per deployment profile.Critical: NOT N service-instances; NOT direct iteration over
identityRoots.mjsIDENTITIES (which would leak Neo-maintainer-team assumptions into framework-portable substrate).Empirical anchor
Operator log evidence:
No equivalent log line for
@neo-gpt. GPT's "lifecycle-driver watchdog" is a custom Codex-side workaround for a canonical substrate gap, not parallel implementation. The canonical orchestrator nudge path is currently family-asymmetric perSwarmHeartbeatService.mjs:102 / :129-131 / :199 / :224single-identity binding.Acceptance Criteria
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_TARGETSenv var (comma-separated) ORorchestrator.swarmHeartbeat.targetsconfig array. Values normalize via existing identity normalization.orchestrator.swarmHeartbeat.targetSource ∈ {self | active-local-team | active-subscribers | disabled}.self— resolved fromNEO_AGENT_IDENTITY/ bound stdio identity /ghlogin fallback. If no identity resolves → disable lane with clear config log (NOT silent fallback to Neo maintainer identity).active-local-team): OPT-IN, usesidentityRoots.mjsIDENTITIES filtered onparticipationStatus === 'active'. Benched identities excluded.npx neo-appworkspaces never receive Neo maintainer wake targets unless explicitly configured.resolveSwarmHeartbeatTargets(). Per-pulse loops atSwarmHeartbeatService.mjs:199and:224consume per-target list, not justthis.identity.test/playwright/unit/ai/daemons/services/SwarmHeartbeatService.spec.mjs:checkSunsetted(identity)per configured target, dispatches per-identity recovery independentlyactive-local-teamprofile: active maintainers included, benched excluded; assertionN active targets → N checkSunsetted callsresolveSwarmHeartbeatTargets()output cardinality. Cross-family parity restored (Opus + GPT both receive nudges per cycle).initAsync({identity})path remains for diagnostic mode.Files (likely touched)
ai/daemons/services/SwarmHeartbeatService.mjs(primary surface — refactor single-identity binding to multi-target resolver)ai/daemons/services/resolveSwarmHeartbeatTargets.mjs(NEW — pure resolver function; testable in isolation)ai/config.template.mjs(NEWorchestrator.swarmHeartbeat.{targets, targetSource}config namespace)test/playwright/unit/ai/daemons/services/SwarmHeartbeatService.spec.mjs(extend coverage)test/playwright/unit/ai/daemons/services/resolveSwarmHeartbeatTargets.spec.mjs(NEW — resolver unit tests)Avoided Traps
identityRoots.mjsIDENTITIES (Cycle-1 source-trace draft I proposed; corrected Cycle-2 by GPT). External consumers (repo forks,npx neo-app, cloud) MUST NOT silently inherit Neo maintainer identities.NEO_AGENT_IDENTITYenv-var as the only resolution path — the env var is theselfstrategy's source, not the universal target source.Related substrate anchors
learn/agentos/decisions/0014-cloud-deployment-topology-and-scheduler-task-taxonomy.mdai/scripts/idleOutNudge.mjs(per-identity sender — confirmed correct shape; not the gap)ai/graph/identityRoots.mjs(Opus + GPTactive, Geminioperator_benchedper ground truth;active-local-teamopt-in source)Authored by: [Claude Opus 4.7] (Claude Code)