Resolves #10673
Authored by Claude Opus 4.7 (Claude Code). Session cce1fea5-32ff-410c-b820-2e9a27b3cd51.
Evolves ai/scripts/checkSunsetted.mjs to emit BOTH sunset and idle_out_candidate signals with explicit evidence fields, supporting the two-mode recovery substrate from Epic #10671. Builds on the in-flight lock primitive merged via #10683 — the detector now consults the lock and downgrades signals to no_action when a recovery action is already in flight.
What changed
Output shape (#10673 contract):
{
"identity": "@neo-opus-ada",
"sunset": false,
"idle_out_candidate": true,
"evidence": {
"subscription_active": true,
"subscription_status": "active",
"last_memory_age_min": 12,
"last_sessionId": "cce1fea5-..."
},
"recommended_action": "idle_out_nudge",
"sunsetted": false,
"reason": "",
"originSessionId": "cce1fea5-...",
"abandonedCount": 0
}The two signals are mutually exclusive by construction:
sunset=true requires subscription_active=false
idle_out_candidate=true requires subscription_active=true
subscription_status distinguishes 4 states the prior detector collapsed into a binary "no active sub": missing | active | degraded | disabled. Consumers can route appropriately on the structured evidence.
In-flight lock integration: when a sunset_restart or idle_out_nudge action is already in flight (per inflightLock.mjs), the corresponding signal downgrades to false and recommended_action becomes no_action. This is the data-layer mutex enforcement preventing the runaway-spawn pattern documented in learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md.
IDLE_THRESHOLD_MS env var matches checkAllAgentIdle.mjs:35 convention (default 10 min) so downstream consumers see consistent idle semantics across detectors.
Deltas from ticket
- AC1 (structured evidence) ✓ — emit
evidence.{subscription_active, subscription_status, last_memory_age_min, last_sessionId} per ticket spec.
- AC2 (preserve #10641 discipline) ✓ —
sunset only fires on missing/disabled/degraded subscription, never on staleness alone. Test #5 (#10641, #10673 annotation) verifies the discipline holds while also asserting the new idle_out_candidate=true signal.
- AC3 (idle_out_candidate logic) ✓ — fires only when
subscription_active=true AND last_memory_age_min > IDLE_THRESHOLD_MS / 60000.
- AC4 (4-quadrant coverage) ✓ — explicit invariant test sweeps 8 scenarios proving
(sunset=T, idle_out=T) is structurally impossible.
- AC5 (backward-compat) ✓ — all 4 legacy fields (
sunsetted / reason / originSessionId / abandonedCount) emitted alongside the new structured payload. Verified swarm-heartbeat.sh:139-145 jq-parsing path uses these exact fields and continues to work without modification.
Test Evidence
npx playwright test test/playwright/unit/ai/scripts/checkSunsetted.spec.mjs → 11 passed (3.0s)
- 5 existing tests pass with new code (regression coverage)
- 6 new tests cover the detector contract surface:
- Structured signals + evidence on missing-subscription path
- Active sub + stale memory →
idle_out_candidate=true
- Active sub + fresh memory →
no_action
subscription_status disambiguation (disabled case)
- In-flight
sunset_restart lock downgrades signal to false
- Backward-compat field shape verified
- 4-quadrant
(T,T)-impossible invariant via 8-scenario sweep
Downstream regression check: resumeHarness.spec.mjs + swarm-heartbeat.spec.mjs + inflightLock.spec.mjs → 21 passed / 2 skipped (the 2 skipped are the live-host RUN_LIVE_OSASCRIPT=1 opt-in tests from #10681). No regression in any consumer.
Post-Merge Validation
Related
- Parent epic: #10671 (substrate-restart recovery, two-mode)
- Builds on: #10683 (in-flight lock primitive, merged) — detector consults the lock for both modes
- Preserves: #10641 discipline (staleness ≠ sunset)
- Unblocks downstream: #10675 idle-out A2A nudge, #10676 sunset-mode restart substrate (both consume
recommended_action)
- Forensic context: PR #10688 (
learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md)
Resolves #10673
Authored by Claude Opus 4.7 (Claude Code). Session cce1fea5-32ff-410c-b820-2e9a27b3cd51.
Evolves
ai/scripts/checkSunsetted.mjsto emit BOTHsunsetandidle_out_candidatesignals with explicit evidence fields, supporting the two-mode recovery substrate from Epic #10671. Builds on the in-flight lock primitive merged via #10683 — the detector now consults the lock and downgrades signals tono_actionwhen a recovery action is already in flight.What changed
Output shape (#10673 contract):
{ "identity": "@neo-opus-ada", "sunset": false, "idle_out_candidate": true, "evidence": { "subscription_active": true, "subscription_status": "active", "last_memory_age_min": 12, "last_sessionId": "cce1fea5-..." }, "recommended_action": "idle_out_nudge", "sunsetted": false, "reason": "", "originSessionId": "cce1fea5-...", "abandonedCount": 0 }The two signals are mutually exclusive by construction:
sunset=truerequiressubscription_active=falseidle_out_candidate=truerequiressubscription_active=truesubscription_statusdistinguishes 4 states the prior detector collapsed into a binary "no active sub":missing | active | degraded | disabled. Consumers can route appropriately on the structured evidence.In-flight lock integration: when a
sunset_restartoridle_out_nudgeaction is already in flight (perinflightLock.mjs), the corresponding signal downgrades tofalseandrecommended_actionbecomesno_action. This is the data-layer mutex enforcement preventing the runaway-spawn pattern documented inlearn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md.IDLE_THRESHOLD_MSenv var matchescheckAllAgentIdle.mjs:35convention (default 10 min) so downstream consumers see consistent idle semantics across detectors.Deltas from ticket
evidence.{subscription_active, subscription_status, last_memory_age_min, last_sessionId}per ticket spec.sunsetonly fires on missing/disabled/degraded subscription, never on staleness alone. Test #5 (#10641, #10673annotation) verifies the discipline holds while also asserting the newidle_out_candidate=truesignal.subscription_active=trueANDlast_memory_age_min > IDLE_THRESHOLD_MS / 60000.(sunset=T, idle_out=T)is structurally impossible.sunsetted/reason/originSessionId/abandonedCount) emitted alongside the new structured payload. Verifiedswarm-heartbeat.sh:139-145jq-parsing path uses these exact fields and continues to work without modification.Test Evidence
npx playwright test test/playwright/unit/ai/scripts/checkSunsetted.spec.mjs→ 11 passed (3.0s)idle_out_candidate=trueno_actionsubscription_statusdisambiguation (disabledcase)sunset_restartlock downgrades signal tofalse(T,T)-impossible invariant via 8-scenario sweepDownstream regression check:
resumeHarness.spec.mjs+swarm-heartbeat.spec.mjs+inflightLock.spec.mjs→ 21 passed / 2 skipped (the 2 skipped are the live-hostRUN_LIVE_OSASCRIPT=1opt-in tests from #10681). No regression in any consumer.Post-Merge Validation
swarm-heartbeat.shcontinues parsing legacy fields correctly in production cronrecommended_action: 'idle_out_nudge'signal correctlyrecommended_action: 'sunset_restart'signal correctlyRelated
recommended_action)learn/agentos/incidents/2026-05-04-runaway-spawn-pattern.md)