Table of Contents
Overview & Motivation
Cron expressions define when and how often heartbeats fire. The resolution pipeline considers multiple factors: global enable state, per-agent config, active hours, reason kind, and channel visibility. This multi-layered approach prevents unnecessary messages while ensuring important channels stay alive.
Think of heartbeats like a radio station's broadcast schedule. The station manager (config) sets the base schedule. Time slots (active hours) determine when to broadcast. Different show types (reason kinds) have different frequencies. Some listeners (channels) opted for silent mode. The DJ (heartbeat runner) checks all these before going on air.
Interval Resolution
Per-agent interval computation from layered config, base + overrides merged at runtime
Active Hours
Time-window gating suppresses heartbeats outside configured operating hours
Reason Classification
HeartbeatReasonKind categorises triggers โ cron, exec, system โ each with distinct intervals
Visibility Control
Per-channel settings: normal, silent, or disabled โ governs which channels receive beats
Heartbeat Interval Resolution
resolveHeartbeatIntervalMs(agentId, config) is the entry point. It reads the agent's
dedicated config section, falls back to the global defaults, and produces a millisecond interval. The
function accounts for minimum-floor clamping (never below 10 000 ms) and ceiling caps defined at the
organisation level.
If a per-agent override is present in config.agents[agentId].heartbeatIntervalMs, it takes
precedence over the global config.heartbeatIntervalMs. The resolved value is cached for the
lifetime of the config object so subsequent calls are effectively free.
Code Walk โ Interval Resolution
Read agent-specific override, falling back to global default.
Clamp the value between the floor (10s) and the org ceiling.
Cache the result on the config object for future lookups.
interface HeartbeatConfig {
heartbeatIntervalMs?: number;
agents?: Record<string, { heartbeatIntervalMs?: number }>;
orgCeilingMs?: number;
}
const FLOOR_MS = 10_000;
function resolveHeartbeatIntervalMs(
agentId: string,
config: HeartbeatConfig
): number {
const agentOverride = config.agents?.[agentId]?.heartbeatIntervalMs;
const base = agentOverride ?? config.heartbeatIntervalMs ?? 60_000;
const ceiling = config.orgCeilingMs ?? 300_000;
return Math.max(FLOOR_MS, Math.min(base, ceiling));
}
Active Hours Gating
The heartbeat-active-hours.ts module provides time-based gating. Each agent can define an
activeHoursRange (UTC) expressed as a pair of 24-hour integers, e.g. [8, 20].
Before every heartbeat cycle, the runner calls isWithinActiveHours(agentId, now). If the
current time falls outside the window, the heartbeat is silently skipped and an internal counter of
"suppressed beats" increments.
When the start hour exceeds the end hour (e.g. [22, 6]), the window wraps around
midnight. The check correctly handles this by splitting the range into two half-open intervals and
checking whether now falls in either one.
Reason Classification
heartbeat-reason.ts exports the HeartbeatReasonKind enum. Each kind maps to a
multiplier applied on top of the base interval:
- Cron โ standard scheduled beat; multiplier 1ร
- ExecCompletion โ fires after a command finishes; multiplier 0.5ร (faster)
- SystemEvent โ kernel / OS-level trigger; multiplier 2ร (slower, less urgent)
- Manual โ explicit API call; bypasses interval entirely
Visibility Rules
Channel visibility is configured per-channel via heartbeat-visibility.ts. Three modes
exist:
HeartbeatVisibility.Normalโ full message contentHeartbeatVisibility.Silentโ delivered without notification soundHeartbeatVisibility.Disabledโ channel receives no heartbeats at all
The resolveVisibilityForChannel(channelId) function reads channel-level overrides first,
then falls back to the workspace default. Disabled channels are filtered before the delivery phase,
meaning no network request is made for them.
Resolution Pipeline
The full resolution pipeline is orchestrated by resolveHeartbeatSummaryForAgent(agentId),
which returns a single HeartbeatSummary object containing every resolved field. This is the
canonical "should we fire?" decision object consumed by the heartbeat runner.
interface HeartbeatSummary {
intervalMs: number;
reason: HeartbeatReasonKind;
visibility: HeartbeatVisibility;
enabled: boolean;
activeHoursOk: boolean;
suppressedCount: number;
}
function resolveHeartbeatSummaryForAgent(
agentId: string
): HeartbeatSummary {
const config = loadAgentConfig(agentId);
const enabled = areHeartbeatsEnabled()
&& isHeartbeatEnabledForAgent(agentId);
const activeHoursOk = isWithinActiveHours(agentId, Date.now());
const reason = classifyCurrentReason(agentId);
const baseInterval = resolveHeartbeatIntervalMs(agentId, config);
const intervalMs = applyReasonMultiplier(baseInterval, reason);
const visibility = resolveVisibilityForChannel(
config.primaryChannelId
);
return {
intervalMs, reason, visibility,
enabled, activeHoursOk,
suppressedCount: getSuppressedCount(agentId),
};
}
flowchart TD
A["areHeartbeatsEnabled()"] -->|yes| B["isHeartbeatEnabledForAgent(agentId)"]
A -->|no| Z["Skip โ globally disabled"]
B -->|yes| C["isWithinActiveHours(agentId, now)"]
B -->|no| Z2["Skip โ agent disabled"]
C -->|yes| D["classifyCurrentReason(agentId)"]
C -->|no| Z3["Skip โ outside active hours"]
D --> E["resolveHeartbeatIntervalMs(agentId, config)"]
E --> F["applyReasonMultiplier(interval, reason)"]
F --> G["resolveVisibilityForChannel(channelId)"]
G -->|Normal/Silent| H["Fire heartbeat"]
G -->|Disabled| Z4["Skip โ channel disabled"]
Heartbeat State Lifecycle
Each heartbeat instance transitions through a well-defined set of states. The state diagram below captures the complete lifecycle, including the cooldown period that prevents rapid re-firing after a successful beat.
stateDiagram-v2
[*] --> Disabled: heartbeats off
Disabled --> Idle: setHeartbeatsEnabled(true)
Idle --> ActiveHoursCheck: timer tick
ActiveHoursCheck --> Idle: outside window
ActiveHoursCheck --> Scheduled: within window
Scheduled --> ReasonEval: interval elapsed
ReasonEval --> VisibilityCheck: reason resolved
VisibilityCheck --> Firing: Normal / Silent
VisibilityCheck --> Idle: Disabled channel
Firing --> Cooldown: beat sent
Cooldown --> Idle: cooldown elapsed
Idle --> Disabled: setHeartbeatsEnabled(false)
Wake Handlers
heartbeat-wake.ts provides manual trigger support. Calling
wakeHeartbeat(agentId, reason?) forces an immediate heartbeat cycle regardless of the
scheduled interval. This is used by the CLI's --heartbeat-now flag and by the REST API's
POST /heartbeat/:agentId/wake endpoint.
Wake calls still respect the active-hours gate and visibility rules โ only the timing constraint is bypassed. If a wake arrives during cooldown, it is queued and fires as soon as cooldown expires.
Enable / Disable Toggles
Two levels of toggle exist:
areHeartbeatsEnabled()/setHeartbeatsEnabled(flag)โ global kill switch stored in the state directory as a boolean flag file.isHeartbeatEnabledForAgent(agentId)โ per-agent override. Agents can be individually disabled without affecting the global state.
The global toggle is checked first. If globally disabled, no per-agent check occurs. This short-circuit makes emergency shutdowns instantaneous.
Disabling heartbeats globally via setHeartbeatsEnabled(false) does not cancel
in-flight heartbeats. Any beat already in the "Firing" state will complete. The disable takes effect on
the next timer tick.