Parent Epic
#11829 — Sub 1 of 5. BLOCKING for cross-family symmetric heartbeat delivery (AC1).
Premise
SwarmHeartbeatService.pulse() currently invokes checkSunsetted + idleOutNudge per this.identity (single-identity binding at ai/daemons/services/SwarmHeartbeatService.mjs:102 + :129-131 + :199 + :224). Per Epic AC1 + AC2: needs deployment-portable resolver so single Orchestrator lane loops over N resolved targets — NOT N service-instances; NOT direct identityRoots iteration.
Prescription
Implement resolveSwarmHeartbeatTargets() with precedence chain per Epic AC2:
- Explicit env/config target list
orchestrator.swarmHeartbeat.targetSource ∈ {self | active-local-team | active-subscribers | disabled}
- Public default
self
- Opt-in
active-local-team uses ai/graph/identityRoots.mjs filtered on participationStatus === 'active' (Neo team profile only)
- Cloud / fork safety per ADR 0014
Wire SwarmHeartbeatService.pulse() to loop over resolved targets, calling checkSunsetted + idleOutNudge per target.
Acceptance Criteria
Avoided Traps (per Epic)
- ❌ Iterating directly over
identityRoots.mjs IDENTITIES as heartbeat target source — registry is Neo-maintainer-team, NOT framework-portable; external consumers MUST NOT silently inherit Neo identities
- ❌ N service-instances instead of single-lane multi-target loop
- ❌ Hardcoding Neo maintainer identities into framework default
Depends on
(none — first sub, no dependencies)
Unblocks
- Sub 2 (#TBD): Layer 1 wake-content enrichment
- Sub 3 (#TBD): Layer 3 per-turn anti-pattern surface
- Sub 4 (#TBD): Layer 5 sunset-handoff pickup queue
- AC7 (Epic): cross-family symmetric validation — GPT's custom Codex nightshift-lifecycle-driver watchdog retirable once this lands
Related
- Epic #11829 (parent)
- Discussion #11823 (graduation source — Cycle-2.7
[GRADUATION_APPROVED])
- ADR 0014 cloud-deployment-topology profile-default precedent
ai/daemons/services/SwarmHeartbeatService.mjs:102/:129-131/:199/:224 — single-identity binding (the substrate gap this sub fixes)
Authored by: Claude Opus 4.7 (Claude Code)
Contract Ledger (added per GPT PR #11913 cycle-1 RA2)
| Surface |
Type |
Default |
Valid Values |
Failure Semantics |
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_TARGET_SOURCE |
env string |
unset → null |
self | active-local-team | active-subscribers | disabled |
invalid value → coerce to null + warn log (resolver then defaults to 'self') |
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_TARGETS |
env CSV string |
unset → null |
comma-separated @handle list (handles auto-normalized to canonical @<id> form) |
empty after normalize → null (falls through to targetSource semantics) |
orchestrator.swarmHeartbeat.targetSource (AiConfig) |
string|null |
null |
same as env var above |
same as env var above |
| Resolver precedence |
function |
explicitTargets > targetSource > 'self' |
(precedence chain) |
unknown source → fallback to 'self' + warn log |
'self' source |
enum branch |
returns [selfIdentity] |
selfIdentity: string|null |
null selfIdentity → returns [] + info log naming NEO_AGENT_IDENTITY + targetSource='disabled' knobs (AC3 disables-with-log) |
'disabled' source |
enum branch |
returns [] + info log |
(none) |
always [] + info log |
'active-local-team' source |
enum branch |
reads identityRoots.IDENTITIES filtered on type==='AgentIdentity' AND properties.participationStatus==='active' |
(none) |
empty filter → returns [] (no log; substrate is empty by design) |
'active-subscribers' source |
enum branch |
unions selfIdentity + injected provider output |
provider: () => Promise<String[]> |
missing provider → fallback to 'self' + warn log + info log if selfIdentity is also null |
beforeSetIdentity (SwarmHeartbeatService) |
config-set hook |
returns null on empty |
string|null |
empty/null value → null (Sub 1 #11905 AC3 pivot: removed DEFAULT_IDENTITY fallback to prevent fork-leak) |
beforeSetTargetSource (SwarmHeartbeatService) |
config-set hook |
returns null on empty/invalid |
one of VALID_TARGET_SOURCES or null |
invalid value → coerce to null + warn log (resolver then defaults to 'self') |
beforeSetExplicitTargets (SwarmHeartbeatService) |
config-set hook |
returns null on empty/non-array |
string[] |
non-array OR empty array → null; per-element normalized via normalizeAgentIdentityNodeId |
Public-contract surface count: 3 env vars + 1 AiConfig key + 4 resolver enum branches + 3 config-set hooks = 11 named surface points. All 11 covered by tests in test/playwright/unit/ai/daemons/orchestrator/scheduling/swarmHeartbeat.spec.mjs (18 pure-function tests) + test/playwright/unit/ai/daemons/orchestrator/services/SwarmHeartbeatService.spec.mjs (5 integration tests + 1 AC3 fork-safety integration test).
Parent Epic
#11829 — Sub 1 of 5. BLOCKING for cross-family symmetric heartbeat delivery (AC1).
Premise
SwarmHeartbeatService.pulse()currently invokescheckSunsetted+idleOutNudgeperthis.identity(single-identity binding atai/daemons/services/SwarmHeartbeatService.mjs:102+:129-131+:199+:224). Per Epic AC1 + AC2: needs deployment-portable resolver so single Orchestrator lane loops over N resolved targets — NOT N service-instances; NOT direct identityRoots iteration.Prescription
Implement
resolveSwarmHeartbeatTargets()with precedence chain per Epic AC2:orchestrator.swarmHeartbeat.targetSource ∈ {self | active-local-team | active-subscribers | disabled}selfactive-local-teamusesai/graph/identityRoots.mjsfiltered onparticipationStatus === 'active'(Neo team profile only)Wire
SwarmHeartbeatService.pulse()to loop over resolved targets, callingcheckSunsetted+idleOutNudgeper target.Acceptance Criteria
resolveSwarmHeartbeatTargets()exported pure-function with 5-step precedence chainpulse()invokes per resolved target (not justthis.identity); orchestrator log shows N nudge lines where N = resolver output cardinalityselfOR disables-with-log — NEVER silently fans out to Neo maintainer identitiestest/playwright/unit/ai/daemons/orchestrator/services/SwarmHeartbeatService.spec.mjsprove: no-config-defaults-self / explicit-target-list / active-local-team / unknown-target-fail-closedAvoided Traps (per Epic)
identityRoots.mjsIDENTITIES as heartbeat target source — registry is Neo-maintainer-team, NOT framework-portable; external consumers MUST NOT silently inherit Neo identitiesDepends on
(none — first sub, no dependencies)
Unblocks
Related
[GRADUATION_APPROVED])ai/daemons/services/SwarmHeartbeatService.mjs:102/:129-131/:199/:224— single-identity binding (the substrate gap this sub fixes)Authored by: Claude Opus 4.7 (Claude Code)
Contract Ledger (added per GPT PR #11913 cycle-1 RA2)
NEO_ORCHESTRATOR_SWARM_HEARTBEAT_TARGET_SOURCEself|active-local-team|active-subscribers|disabled'self')NEO_ORCHESTRATOR_SWARM_HEARTBEAT_TARGETS@handlelist (handles auto-normalized to canonical@<id>form)targetSourcesemantics)orchestrator.swarmHeartbeat.targetSource(AiConfig)nullexplicitTargets > targetSource > 'self''self'+ warn log'self'source[selfIdentity]selfIdentity: string|nullnull selfIdentity→ returns[]+ info log namingNEO_AGENT_IDENTITY+targetSource='disabled'knobs (AC3 disables-with-log)'disabled'source[]+ info log[]+ info log'active-local-team'sourceidentityRoots.IDENTITIESfiltered ontype==='AgentIdentity'ANDproperties.participationStatus==='active'[](no log; substrate is empty by design)'active-subscribers'sourceselfIdentity+ injected provider output() => Promise<String[]>'self'+ warn log + info log ifselfIdentityis also nullbeforeSetIdentity(SwarmHeartbeatService)nullon emptynull(Sub 1 #11905 AC3 pivot: removedDEFAULT_IDENTITYfallback to prevent fork-leak)beforeSetTargetSource(SwarmHeartbeatService)nullon empty/invalidVALID_TARGET_SOURCESor null'self')beforeSetExplicitTargets(SwarmHeartbeatService)nullon empty/non-arraynull; per-element normalized vianormalizeAgentIdentityNodeIdPublic-contract surface count: 3 env vars + 1 AiConfig key + 4 resolver enum branches + 3 config-set hooks = 11 named surface points. All 11 covered by tests in
test/playwright/unit/ai/daemons/orchestrator/scheduling/swarmHeartbeat.spec.mjs(18 pure-function tests) +test/playwright/unit/ai/daemons/orchestrator/services/SwarmHeartbeatService.spec.mjs(5 integration tests + 1 AC3 fork-safety integration test).