Skip to content

fix(heartbeat): normalize system event session keys to lowercase#178

Open
BingqingLyu wants to merge 1 commit into
mainfrom
fork-pr-40209-fix-system-events-case-normalization
Open

fix(heartbeat): normalize system event session keys to lowercase#178
BingqingLyu wants to merge 1 commit into
mainfrom
fork-pr-40209-fix-system-events-case-normalization

Conversation

@BingqingLyu

@BingqingLyu BingqingLyu commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • Problem: requestHeartbeatNow({ sessionKey }) silently produces no visible output for channel sessions (agent:main:slack:channel:*) while working correctly for main/thread sessions.
  • Why it matters: Plugins (like event bridges) that use requestHeartbeatNow to wake channel sessions cannot reliably deliver system events — the events sit in the queue indefinitely and the heartbeat turn is pruned as if nothing happened.
  • What changed: requireSessionKey() in system-events.ts now lowercases the key, matching the canonical form used by parseAgentSessionKey() throughout the codebase. Two new tests verify cross-case enqueue/peek and deduplication.
  • What did NOT change: No changes to the heartbeat runner, wake mechanism, or session resolution. The fix is scoped entirely to the system events in-memory queue.

Change Type

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature
  • 💥 Breaking change
  • 📝 Documentation update
  • 🔧 Refactoring (no behavior change)
  • ⚡ Performance improvement
  • 🧪 Test update

Scope

  • Core functionality
  • Channel (Slack, Telegram, etc.)
  • CLI
  • UI (Control Panel / Settings)
  • Documentation
  • Infrastructure / CI

Linked Issue/PR

Closes openclaw#34338

User-visible / Behavior Changes

System events enqueued with mixed-case session keys (e.g., Slack channel IDs like C0AKA9RBSAU) are now correctly found by the heartbeat runner's peek/drain calls. Previously, the heartbeat runner normalized session keys to lowercase via parseAgentSessionKey() but the system events queue stored them verbatim — causing a key mismatch for any session key containing uppercase characters.

Before: Plugin enqueues event → heartbeat fires → event not found (case mismatch) → model responds HEARTBEAT_OK → turn pruned → appears as "no heartbeat turn ran"

After: Plugin enqueues event → key normalized to lowercase → heartbeat finds event → model acts on it → visible output

Security Impact

  1. Does this change handle user input? No
  2. Does it affect authentication or authorization? No
  3. Does it introduce new API endpoints? No
  4. Does it change data storage or handling? No (in-memory map only)
  5. Does it affect cryptographic operations? No

Repro + Verification

Environment: Any setup with a plugin calling requestHeartbeatNow for a channel session.

Steps to reproduce (before fix):

  1. Enqueue a system event targeting a channel session: enqueueSystemEvent("test", { sessionKey: "agent:main:slack:channel:C0XX" })
  2. Peek with the canonical lowercase key: peekSystemEvents("agent:main:slack:channel:c0xx")
  3. Returns [] — event is invisible

Expected: Event should be found regardless of key casing.

Actual (after fix): peekSystemEvents("agent:main:slack:channel:c0xx") returns ["test"]

Evidence

New tests added to src/infra/system-events.test.ts:

  • normalizes session key case so channel sessions match — enqueues with uppercase Slack ID, peeks with lowercase, verifies match
  • deduplicates across case variants of the same session key — enqueues from two case variants, verifies both land in the same queue
 ✓ src/infra/system-events.test.ts (13 tests) 20ms
 ✓ src/infra/heartbeat-runner.scheduler.test.ts (8 tests) 31ms
 ✓ src/infra/heartbeat-wake.test.ts (13 tests) 25ms

All 34 tests pass across system-events, heartbeat-runner scheduler, and heartbeat-wake.

Human Verification

  • Traced the full call chain from requestHeartbeatNow() → heartbeat-wake → heartbeat-runner → runHeartbeatOnce()resolveHeartbeatSession()peekSystemEventEntries()getReplyFromConfig()drainSystemEventEntries()
  • Confirmed that parseAgentSessionKey() (used by resolveHeartbeatSession) lowercases all keys, while requireSessionKey() (used by system events) did not
  • Verified that main/thread session keys are already lowercase, explaining why only channel sessions are affected
  • Did NOT verify with a live Hermes plugin end-to-end (would require a multi-container test setup)

Compatibility / Migration

Fully backward compatible. The only behavioral change is that system event keys are now case-insensitive. Any code already passing lowercase keys sees no change. Code passing mixed-case keys now works correctly instead of silently failing.

Failure Recovery

One-line revert in requireSessionKey() (change return trimmed.toLowerCase() back to return trimmed). No data migration — system events are ephemeral (in-memory only).

Risks and Mitigations

Risk Mitigation
Existing code relies on case-sensitive system event keys Extremely unlikely — all session key generation normalizes to lowercase. The mixed-case path was a bug, not a feature.
Performance impact of .toLowerCase() on every system event call Negligible — called at most ~20 times per session per heartbeat cycle on short strings

🤖 Generated with Claude Code · AI-assisted: fully tested with existing + new unit tests, code traced end-to-end through the heartbeat pipeline.

System events use an in-memory map keyed by session key. The
requireSessionKey() helper only trimmed the key without case
normalization, but session keys are canonicalized to lowercase by
parseAgentSessionKey() throughout the rest of the codebase.

When a plugin (e.g. Hermes) enqueues a system event with a mixed-case
session key like `agent:main:slack:channel:C0AKA9RBSAU`, the event is
stored under the mixed-case key. Later, when the heartbeat runner peeks
or drains events using the canonical lowercase key, the lookup misses
the event entirely. The heartbeat still fires but sees no pending events,
so the model responds with HEARTBEAT_OK and the turn is pruned — making
it appear as if no heartbeat ran at all.

Main/thread sessions are unaffected because their keys
(`agent:main:main`, `agent:main:main:thread:*`) are already lowercase.
Channel sessions with platform-specific IDs (Slack C0XX, Discord guild
IDs, etc.) are the ones that hit this mismatch.

Fix: lowercase the key in requireSessionKey() so all enqueue/peek/drain
operations use the same canonical form, matching the rest of the session
key infrastructure.

Closes openclaw#34338
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

requestHeartbeatNow with sessionKey silently skips for channel sessions (agent:main:slack:channel:*)

1 participant