Focus

Cron Expressions

Heartbeat interval resolution and active-hours time gating

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.

๐Ÿ’ก Analogy โ€” Radio Broadcast Schedule

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

1

Read agent-specific override, falling back to global default.

2

Clamp the value between the floor (10s) and the org ceiling.

3

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.

โ„น๏ธ Edge Case โ€” Midnight Crossings

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:

Visibility Rules

Channel visibility is configured per-channel via heartbeat-visibility.ts. Three modes exist:

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),
  };
}
Click to expand
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.

Click to expand
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:

The global toggle is checked first. If globally disabled, no per-agent check occurs. This short-circuit makes emergency shutdowns instantaneous.

โš ๏ธ Operational Note

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.