Context
Surfaced empirically by @neo-gpt during #10318 heartbeat token-economy measurement (PR #10594, see ## TTL Sweeper Caveat section of learn/agentos/measurements/heartbeat-token-economy-2026-05.md).
ai/scripts/sweepExpiredTasks.mjs direct-invocation currently exits non-zero after ~116ms with:
ReferenceError: Neo is not defined
The swarm-heartbeat.sh script invokes the sweeper before its token-economy fast path, redirecting stderr to /dev/null. This silently masks the failure — expired falls back to 0 and the heartbeat loop continues without surfacing the regression.
The Problem
Two compounding harms:
Heartbeat-driven task expiration is structurally broken. Epic #10311 (Track 2C, #10339 closed) shipped the sweeper as the cron-driven mechanism for transitioning stale tasks to the Expired state. The current Neo is not defined regression means no automated task expiration is occurring — task-state-machine claims about TTL behavior are aspirational, not empirical.
Silent stderr-redirect masks the failure. The heartbeat loop runs the sweeper roughly 288 times/day (5-min cadence) with no operator-visible signal that it's failing every single invocation. ~116ms × 288 × N agents = wall-clock waste that contributes nothing while the regression persists. Per the empirical anchor in PR #10594's measurement page: "the local graph contained no A2A Task nodes during measurement, so no task state was changed by this probe" — the regression's behavioral impact is invisible until A2A Task nodes accumulate, at which point expiration silently doesn't happen.
The Architectural Reality
The error message — ReferenceError: Neo is not defined — is the canonical symptom of a script that consumes the Neo class system without first importing the substrate (src/Neo.mjs + src/core/_export.mjs). Sibling scripts in buildScripts/ai/ (e.g., runSandman.mjs) explicitly import:
import Neo from '../../src/Neo.mjs';
import * as core from '../../src/core/_export.mjs';
import InstanceManager from '../../src/manager/Instance.mjs';
If sweepExpiredTasks.mjs either doesn't import these or imports a SystemLifecycleService / GraphService entry that depends on Neo.setupClass() being available at module-load time, the failure shape matches. Empirical inspection of the script's imports + dependency chain is Phase 1 of the fix.
The Fix
Phase 1 — Diagnose: Read ai/scripts/sweepExpiredTasks.mjs head, identify import chain gap by comparing against working sibling buildScripts/ai/runSandman.mjs.
Phase 2 — Targeted patch: Typically one of: missing import Neo + import * as core; reorder imports; or move the script under buildScripts/ai/ if path-resolution differs.
Phase 3 — Stop the silent mask: swarm-heartbeat.sh currently redirects sweeper stderr to /dev/null. After regression fix, surface to operator log instead.
Phase 4 — Lock with test: Add Playwright unit-test under test/playwright/unit/ai/scripts/sweepExpiredTasks.spec.mjs against an empty-graph fixture.
Acceptance Criteria
Out of Scope
- Refactoring
swarm-heartbeat.sh itself beyond the stderr-redirect tightening.
- Changing the heartbeat cadence (5-min default validated by #10318 measurement).
- Migrating the sweeper to a different substrate (#10186 / #10311 govern the broader substrate; this is the narrow regression fix).
- Re-enabling
autoDream / autoGoldenPath (per #10569 hard-stop).
Avoided Traps
- Trap: re-write the sweeper to bypass Neo.setupClass. Rejected — Neo is the canonical substrate; bypassing diverges from the rest of buildScripts/ai. Single-import-fix shape is right.
- Trap: leave the stderr redirect alone. Rejected — the silent-mask is itself a discipline gap that allowed this regression to persist undetected. Tightening prevents the next regression from hiding for as long.
- Trap: bundle with #10319 heartbeat concurrency. Rejected — different mechanical concern (sweeper invocation vs heartbeat-pulse mutex).
Related
- Empirical anchor: PR #10594 —
## TTL Sweeper Caveat section.
- Parent epic: #10311 Track 2C task expiration.
- Adjacent: #10339 (closed; original sweeper implementation).
Origin Session ID: 86b7a3a0-7b14-4bd1-b707-52c5741aaeeb
Retrieval Hint: "sweepExpiredTasks.mjs Neo is not defined ReferenceError TTL sweeper heartbeat regression"
Context
Surfaced empirically by @neo-gpt during #10318 heartbeat token-economy measurement (PR #10594, see
## TTL Sweeper Caveatsection oflearn/agentos/measurements/heartbeat-token-economy-2026-05.md).ai/scripts/sweepExpiredTasks.mjsdirect-invocation currently exits non-zero after ~116ms with:The
swarm-heartbeat.shscript invokes the sweeper before its token-economy fast path, redirecting stderr to/dev/null. This silently masks the failure —expiredfalls back to0and the heartbeat loop continues without surfacing the regression.The Problem
Two compounding harms:
Heartbeat-driven task expiration is structurally broken. Epic #10311 (Track 2C, #10339 closed) shipped the sweeper as the cron-driven mechanism for transitioning stale tasks to the
Expiredstate. The currentNeo is not definedregression means no automated task expiration is occurring — task-state-machine claims about TTL behavior are aspirational, not empirical.Silent stderr-redirect masks the failure. The heartbeat loop runs the sweeper roughly 288 times/day (5-min cadence) with no operator-visible signal that it's failing every single invocation. ~116ms × 288 × N agents = wall-clock waste that contributes nothing while the regression persists. Per the empirical anchor in PR #10594's measurement page: "the local graph contained no A2A Task nodes during measurement, so no task state was changed by this probe" — the regression's behavioral impact is invisible until A2A Task nodes accumulate, at which point expiration silently doesn't happen.
The Architectural Reality
The error message —
ReferenceError: Neo is not defined— is the canonical symptom of a script that consumes the Neo class system without first importing the substrate (src/Neo.mjs+src/core/_export.mjs). Sibling scripts inbuildScripts/ai/(e.g.,runSandman.mjs) explicitly import:import Neo from '../../src/Neo.mjs'; import * as core from '../../src/core/_export.mjs'; import InstanceManager from '../../src/manager/Instance.mjs';If
sweepExpiredTasks.mjseither doesn't import these or imports a SystemLifecycleService / GraphService entry that depends onNeo.setupClass()being available at module-load time, the failure shape matches. Empirical inspection of the script's imports + dependency chain is Phase 1 of the fix.The Fix
Phase 1 — Diagnose: Read
ai/scripts/sweepExpiredTasks.mjshead, identify import chain gap by comparing against working siblingbuildScripts/ai/runSandman.mjs.Phase 2 — Targeted patch: Typically one of: missing
import Neo+import * as core; reorder imports; or move the script underbuildScripts/ai/if path-resolution differs.Phase 3 — Stop the silent mask:
swarm-heartbeat.shcurrently redirects sweeper stderr to/dev/null. After regression fix, surface to operator log instead.Phase 4 — Lock with test: Add Playwright unit-test under
test/playwright/unit/ai/scripts/sweepExpiredTasks.spec.mjsagainst an empty-graph fixture.Acceptance Criteria
node ai/scripts/sweepExpiredTasks.mjsexits 0 on a clean local checkout (currently exits non-zero withReferenceError).swarm-heartbeat.sh— sweeper failures surface to operator log, not/dev/null.Out of Scope
swarm-heartbeat.shitself beyond the stderr-redirect tightening.autoDream/autoGoldenPath(per #10569 hard-stop).Avoided Traps
Related
## TTL Sweeper Caveatsection.Origin Session ID: 86b7a3a0-7b14-4bd1-b707-52c5741aaeeb Retrieval Hint: "sweepExpiredTasks.mjs Neo is not defined ReferenceError TTL sweeper heartbeat regression"