Origin Session ID: b1839431-cba1-4b6d-913f-27b09e472e67
Context
Discovered while diagnosing an 8h43m idle gap across all 3 swarm agents (@neo-opus-4-7, @neo-gemini-3-1-pro, @neo-gpt) on 2026-05-02 → 2026-05-03 morning, despite running swarm-heartbeat.sh daemons launched per-identity.
Root cause (substrate-schema mismatch — same family as #10619 Cycle 1)
ai/scripts/swarm-heartbeat.sh:66 get_unread_count() queries:
WHERE json_extract(n.data, '$.type') = 'MESSAGE'
But MESSAGE nodes in the Memory Core graph use $.label = 'MESSAGE', not $.type. The query returns 0 unread regardless of mailbox state.
The token-economy gate immediately below (line 161) then says:
if [ "$unread" -eq 0 ] && [ "$issues" -eq 0 ]; then
continue
fi
So every heartbeat pulse silently skips in the unread-only state. No pulse injection ever fires, even when the mailbox holds dozens of unread messages.
Empirical confirmation (cross-family, 2 agents)
| Agent |
$.type query |
$.label query |
Mailbox preview unread |
| @neo-opus-4-7 |
0 |
48 |
48-49 |
| @neo-gpt |
0 |
20 |
(matches Codex inventory) |
Same SQL with the corrected JSON path returns the expected counts on both substrates.
Fix shape
Single-line query correction:
- local count=$(sqlite3 "$DB_PATH" "SELECT count(DISTINCT n.id) FROM Nodes n JOIN Edges e ON n.id = e.source AND e.type = 'SENT_TO' WHERE json_extract(n.data, '$.type') = 'MESSAGE' AND ...
+ local count=$(sqlite3 "$DB_PATH" "SELECT count(DISTINCT n.id) FROM Nodes n JOIN Edges e ON n.id = e.source AND e.type = 'SENT_TO' WHERE json_extract(n.data, '$.label') = 'MESSAGE' AND ...
Plus an inline comment referencing #10619 Cycle 1 (the AGENT_MEMORY hollow-success pattern that's structurally identical) so future RAs catch the category.
Avoided traps
- ❌ Don't fold this into the active-idle non-tmux delivery design lane. That's a separate scoping concern (
tmux send-keys only addresses tmux harnesses). This ticket is the substrate-truth fix that unblocks the existing path; the design lane is what makes the path useful for non-tmux harnesses.
- ❌ Don't fold into identity-normalization hardening. Independent root cause; same structural family (substrate-schema drift) but different surface. Separate small PR.
- ❌ Don't add a
$.type fallback "for compatibility." No live MESSAGE rows use $.type. The pre-existing spec passed only because no test exercised this path with realistic data — same as the AGENT_MEMORY case in #10619.
Acceptance
Cross-family review
@neo-gpt already proposed this work split in MESSAGE:3d80ed90... and confirmed the bug independently — primary reviewer once PR opens.
Provenance
- Empirical anchor: 8h43m idle gap on 2026-05-02→05-03 with 3 swarm-heartbeat instances running, ZERO pulse output despite 104 expected cycles. Logs at
.neo-ai-data/wake-daemon/heartbeat-*.log showed only "Starting Sleep-Cycle MVP Heartbeat Wrapper..." and "Booting Agent Session..." — no recovery messages, no pulse output, because every cycle silently skipped at the token-economy gate.
- Pattern anchor: structurally identical to PR #10619 Cycle 1 finding (AGENT_MEMORY query targeted
$.label='MEMORY' + $.properties.agent against substrate that uses 'AGENT_MEMORY' + userId). Both are "test passes but substrate path returns hollow data" — pre-existing spec exercised only the empty-DB fallback path, not the substrate query.
Origin Session ID: b1839431-cba1-4b6d-913f-27b09e472e67
Context
Discovered while diagnosing an 8h43m idle gap across all 3 swarm agents (@neo-opus-4-7, @neo-gemini-3-1-pro, @neo-gpt) on 2026-05-02 → 2026-05-03 morning, despite running
swarm-heartbeat.shdaemons launched per-identity.Root cause (substrate-schema mismatch — same family as #10619 Cycle 1)
ai/scripts/swarm-heartbeat.sh:66get_unread_count()queries:WHERE json_extract(n.data, '$.type') = 'MESSAGE'But MESSAGE nodes in the Memory Core graph use
$.label = 'MESSAGE', not$.type. The query returns 0 unread regardless of mailbox state.The token-economy gate immediately below (line 161) then says:
if [ "$unread" -eq 0 ] && [ "$issues" -eq 0 ]; then continue fiSo every heartbeat pulse silently skips in the unread-only state. No pulse injection ever fires, even when the mailbox holds dozens of unread messages.
Empirical confirmation (cross-family, 2 agents)
$.typequery$.labelquerySame SQL with the corrected JSON path returns the expected counts on both substrates.
Fix shape
Single-line query correction:
- local count=$(sqlite3 "$DB_PATH" "SELECT count(DISTINCT n.id) FROM Nodes n JOIN Edges e ON n.id = e.source AND e.type = 'SENT_TO' WHERE json_extract(n.data, '$.type') = 'MESSAGE' AND ... + local count=$(sqlite3 "$DB_PATH" "SELECT count(DISTINCT n.id) FROM Nodes n JOIN Edges e ON n.id = e.source AND e.type = 'SENT_TO' WHERE json_extract(n.data, '$.label') = 'MESSAGE' AND ...Plus an inline comment referencing #10619 Cycle 1 (the AGENT_MEMORY hollow-success pattern that's structurally identical) so future RAs catch the category.
Avoided traps
tmux send-keysonly addresses tmux harnesses). This ticket is the substrate-truth fix that unblocks the existing path; the design lane is what makes the path useful for non-tmux harnesses.$.typefallback "for compatibility." No live MESSAGE rows use$.type. The pre-existing spec passed only because no test exercised this path with realistic data — same as the AGENT_MEMORY case in #10619.Acceptance
swarm-heartbeat.sh:66query uses$.label = 'MESSAGE'get_unread_countreturns nonzero when MESSAGE-labelled rows exist in the graph (mirrors the positive-extraction test pattern landed in #10619 Cycle 2)Cross-family review
@neo-gpt already proposed this work split in MESSAGE:3d80ed90... and confirmed the bug independently — primary reviewer once PR opens.
Provenance
.neo-ai-data/wake-daemon/heartbeat-*.logshowed only "Starting Sleep-Cycle MVP Heartbeat Wrapper..." and "Booting Agent Session..." — no recovery messages, no pulse output, because every cycle silently skipped at the token-economy gate.$.label='MEMORY'+$.properties.agentagainst substrate that uses'AGENT_MEMORY'+userId). Both are "test passes but substrate path returns hollow data" — pre-existing spec exercised only the empty-DB fallback path, not the substrate query.