Infrastructure/Scheduling/ — Time-Dependent Operations & Retry Orchestration

Heartbeats, graceful restarts, and delivery-queue recovery

The scheduling sub-module orchestrates every time-dependent operation in OpenClaw: periodic heartbeat messages that keep channels alive, coordinated process restarts that avoid losing in-flight messages, and exponential-backoff delivery recovery that rescues failed messages across 5 retry tiers and 3 OS platforms.

~25
Source Files
~8K
Lines of Code
3
OS Platforms
5
Retry Tiers

Heartbeats Graceful Restart Backoff & Retry Delivery Recovery

📚 What is this & why does it exist?

Scheduling orchestrates all time-dependent operations: periodic heartbeat messages, graceful process restarts, and delivery retry recovery. Without it, there would be no keepalive signals to channels, no way to restart without losing in-flight messages, and failed deliveries would be permanently lost.

💡 Real-world analogy

Think of scheduling like an airport control tower. The heartbeat runner is like the regular “all clear” radio check every few minutes. The restart controller is like coordinating a runway closure—you don’t shut it down until all planes in the air have landed (deferral polling). The delivery queue recovery is like re-routing delayed flights with increasing wait times between attempts.

Contents

Architecture

The scheduling sub-module is split into three pillars: heartbeat management (periodic keepalive signals), restart coordination (graceful process lifecycle), and delivery recovery (retry with backoff). Each pillar operates independently but shares common backoff and retry primitives.

flowchart TD
  subgraph Heartbeats["Heartbeat Management"]
    HR["heartbeat-runner.ts\nareHeartbeatsEnabled()\nsetHeartbeatsEnabled()"]
    HAH["heartbeat-active-hours.ts\nTime-based gating"]
    HRE["heartbeat-reason.ts\nReason classification"]
    HVI["heartbeat-visibility.ts\nChannel visibility rules"]
    HWK["heartbeat-wake.ts\nWake handlers"]
  end
  subgraph Restarts["Restart Coordination"]
    RST["restart.ts\nscheduleGatewaySigusr1Restart()\ndeferGatewayRestartUntilIdle()"]
    RESP["process-respawn.ts\nrestartGatewayProcessWithFreshPid()"]
    SUPV["supervisor-markers.ts\ndetectRespawnSupervisor()"]
  end
  subgraph Recovery["Delivery Recovery"]
    DQR["delivery-queue-recovery.ts\nrecoverPendingDeliveries()"]
    BOFF["backoff.ts\ncomputeBackoff()"]
    RTRY["retry.ts\nretryAsync()"]
  end
  HR --> HAH
  HR --> HRE
  HR --> HVI
  HR --> HWK
  RST --> RESP
  RESP --> SUPV
  DQR --> BOFF
  DQR --> RTRY
  RST --> BOFF

Data Flow

This sequence diagram shows how the three scheduling pillars interact during a typical gateway lifecycle: heartbeats run on their interval, a restart is requested and deferred until idle, and any failed deliveries trigger recovery with exponential backoff.

sequenceDiagram
    participant Config as Configuration
    participant HB as heartbeat-runner.ts
    participant Channel as Target Channel
    participant RST as restart.ts
    participant RESP as process-respawn.ts
    participant SUPV as supervisor-markers.ts
    participant DQR as delivery-queue-recovery.ts
    participant BOFF as backoff.ts

    Config->>HB: resolveHeartbeatIntervalMs(agentId, config)
    HB->>HB: areHeartbeatsEnabled() check
    HB->>HB: isHeartbeatEnabledForAgent(agentId)
    HB->>Channel: Send heartbeat pulse
    Channel-->>HB: HeartbeatEventPayload { status: "sent" }
    Note over HB,Channel: Repeats on resolved interval

    RST->>RST: setPreRestartDeferralCheck(fn)
    RST->>RST: deferGatewayRestartUntilIdle(opts)
    loop Deferral polling (max 300s)
        RST->>RST: Check in-flight messages
    end
    RST->>RST: authorizeGatewaySigusr1Restart(delayMs)
    RST->>RESP: restartGatewayProcessWithFreshPid()
    RESP->>SUPV: detectRespawnSupervisor(env, platform)
    SUPV-->>RESP: RespawnSupervisor | null
    RESP-->>RST: GatewayRespawnResult { mode, pid }

    DQR->>DQR: recoverPendingDeliveries(opts)
    loop Each failed delivery (max 5 retries)
        DQR->>DQR: isEntryEligibleForRecoveryRetry()
        DQR->>DQR: isPermanentDeliveryError() check
        DQR->>BOFF: computeBackoffMs()
        BOFF-->>DQR: delay [5s, 25s, 120s, 600s]
        DQR->>Channel: Retry delivery
    end
    DQR-->>RST: RecoverySummary { recovered, failed }

Heartbeat Pipeline

The heartbeat system maintains channel liveness by periodically emitting keepalive signals. The pipeline resolves whether heartbeats are globally enabled, whether a specific agent should participate, calculates the interval, determines visibility rules, and finally emits a HeartbeatEventPayload.

heartbeat-runner.ts

The core heartbeat orchestrator at 250+ lines. Exports the HeartbeatDeps dependency type which composes OutboundSendDeps, ChannelHeartbeatDeps, and optional runtime hooks (runtime?, getQueueSize?, nowMs?). Key functions:

FunctionReturnsPurpose
areHeartbeatsEnabled()booleanGlobal heartbeat kill switch
setHeartbeatsEnabled()voidToggle global heartbeat state
isHeartbeatEnabledForAgent(agentId)booleanPer-agent heartbeat gate
resolveHeartbeatIntervalMs(agentId, config)numberCompute interval in ms for a given agent
resolveHeartbeatSummaryForAgent(agentId)HeartbeatSummaryFull summary including reason, visibility, enabled flag

Supporting Modules

FilePurpose
heartbeat-active-hours.tsActive hours calculation—time-based gating that suppresses heartbeats outside configured windows
heartbeat-reason.tsClassifies why a heartbeat was triggered: cron event, exec completion, or system event (HeartbeatReasonKind)
heartbeat-visibility.tsDetermines channel visibility rules (HeartbeatVisibility) for silent vs. visible heartbeats
heartbeat-wake.tsWake handlers for manual heartbeat triggers and on-demand channel pings

Restart Coordination

Graceful restart is critical for zero-downtime deployments. The restart subsystem ensures in-flight messages are drained before the process is replaced, supports three OS-specific restart methods, and enforces cooldown periods to prevent restart storms.

restart.ts

The restart controller at 500+ lines. Manages the full lifecycle from deferral to signal emission:

FunctionReturnsPurpose
setPreRestartDeferralCheck(fn)voidRegister a callback that must return true before restart proceeds
authorizeGatewaySigusr1Restart(delayMs?)voidAuthorize a SIGUSR1-based restart with optional delay
emitGatewayRestart()voidEmit restart event for “emit” mode listeners
deferGatewayRestartUntilIdle(opts)ScheduledRestartWait for idle state before restarting (max 300s default)
scheduleGatewaySigusr1Restart(opts?)ScheduledRestartSchedule a SIGUSR1 restart with coalescing and cooldown
triggerOpenClawRestart()voidTop-level restart entry point

Constants

ConstantValueDescription
SIGUSR1_AUTH_GRACE_MS5000Grace period after SIGUSR1 authorization
DEFAULT_DEFERRAL_MAX_WAIT_MS300000Maximum deferral wait (5 minutes) before forcing restart
RESTART_COOLDOWN_MS30000Minimum interval between consecutive restarts

process-respawn.ts

restartGatewayProcessWithFreshPid() returns a GatewayRespawnResult with one of four modes:

ModeDescription
"spawned"New process launched directly
"supervised"Delegated to detected supervisor (launchd, systemd, schtasks)
"disabled"Respawn disabled via OPENCLAW_NO_RESPAWN environment variable
"failed"Respawn attempt failed; detail string explains why

supervisor-markers.ts

detectRespawnSupervisor(env, platform) inspects environment variables and the OS platform to detect if the process is managed by launchd (macOS), systemd (Linux), or schtasks (Windows). Returns RespawnSupervisor | null.

Delivery Recovery

When message delivery fails, the recovery subsystem retries with exponential backoff, filters out permanent errors, and produces a summary of outcomes.

delivery-queue-recovery.ts

Core recovery orchestrator with MAX_RETRIES=5 and a backoff schedule of [5s, 25s, 120s, 600s]. Includes 14 compiled regex patterns for permanent error detection.

FunctionReturnsPurpose
recoverPendingDeliveries(opts)RecoverySummaryMain entry point; iterates pending entries and retries eligible ones
computeBackoffMs()numberReturns delay in ms for current retry attempt
isPermanentDeliveryError()booleanTests error against 14 permanent error patterns
isEntryEligibleForRecoveryRetry()booleanChecks retry count, backoff, and error permanence

backoff.ts & retry.ts

Shared primitives used across all scheduling pillars:

ExportFileDescription
BackoffPolicy { initialMs, maxMs, factor, jitter }backoff.tsPolicy object defining backoff curve parameters
computeBackoff(policy, attempt)backoff.tsPure function: returns delay ms for a given attempt number
sleepWithAbort(ms, abortSignal?)backoff.tsPromise-based sleep that can be cancelled via AbortSignal
RetryConfig { attempts?, minDelayMs?, maxDelayMs?, jitter? }retry.tsConfiguration for generic retry wrapper
retryAsync<T>(fn, attemptsOrOptions)retry.tsGeneric retry wrapper; defaults to 3 attempts, 300ms min, 30s max, exponential 2^attempt

Key Classes & Functions

Class / FunctionFilePurpose
areHeartbeatsEnabled()heartbeat-runner.tsGlobal heartbeat kill switch query
resolveHeartbeatSummaryForAgent()heartbeat-runner.tsFull heartbeat summary with interval, reason, visibility
deferGatewayRestartUntilIdle()restart.tsPolls until idle, then triggers graceful restart
scheduleGatewaySigusr1Restart()restart.tsCoalesced SIGUSR1 restart with cooldown enforcement
restartGatewayProcessWithFreshPid()process-respawn.tsOS-aware process replacement
detectRespawnSupervisor()supervisor-markers.tsDetects launchd / systemd / schtasks supervisor
recoverPendingDeliveries()delivery-queue-recovery.tsRetry failed deliveries with exponential backoff
computeBackoff()backoff.tsPure backoff delay calculation
retryAsync<T>()retry.tsGeneric async retry wrapper with configurable policy

Data Structures

KindNameDescription
typeHeartbeatDepsOutboundSendDeps & ChannelHeartbeatDeps & { runtime?, getQueueSize?, nowMs? }
typeHeartbeatSummary{ intervalMs, reason: HeartbeatReasonKind, visibility: HeartbeatVisibility, enabled }
typeHeartbeatEventPayload{ ts, status: "sent"|"ok-empty"|"ok-token"|"skipped"|"failed", to?, accountId?, preview?, durationMs?, channel?, silent?, indicatorType? }
typeRestartAttempt{ ok, method: "launchctl"|"systemd"|"schtasks"|"supervisor", detail?, tried? }
typeScheduledRestart{ ok, pid, signal: "SIGUSR1", delayMs, reason?, mode: "emit"|"signal", coalesced, cooldownMsApplied }
typeRestartAuditInfo{ actor?, deviceId?, clientIp?, changedPaths? }
typeGatewayRespawnResult{ mode: RespawnMode, pid?, detail? }
enumRespawnMode"spawned" | "supervised" | "disabled" | "failed"
typeRecoverySummary{ recovered, failed, skippedMaxRetries, deferredBackoff }
typeBackoffPolicy{ initialMs, maxMs, factor, jitter }
typeRetryConfig{ attempts?, minDelayMs?, maxDelayMs?, jitter? }

Interactions

DirectionPartnerInterfaceDescription
→Channel SystemOutboundSendDepsHeartbeats emit keepalive signals to target channels
→OS Process ManagerSIGUSR1 / launchctl / systemd / schtasksRestart coordination delegates to platform-specific supervisor
→Delivery QueuerecoverPendingDeliveries()Retries pending deliveries from the failed message queue
←GatewaytriggerOpenClawRestart()Gateway requests graceful restart via scheduling
←Config SystemresolveHeartbeatIntervalMs()Configuration provides per-agent heartbeat intervals
→Audit LogRestartAuditInfoRestart events are logged with actor, device, IP, and changed paths
↔backoff.ts / retry.tscomputeBackoff(), retryAsync()Shared retry primitives used by both restart and recovery pillars

Configuration

Knob / ParameterDefaultDescription
SIGUSR1_AUTH_GRACE_MS5000Grace period (ms) after SIGUSR1 is authorized before signal is sent
DEFAULT_DEFERRAL_MAX_WAIT_MS300000Maximum idle-wait before restart is forced (5 minutes)
RESTART_COOLDOWN_MS30000Minimum interval between consecutive restarts (30 seconds)
MAX_RETRIES5Maximum delivery recovery retry attempts per entry
Backoff schedule[5s, 25s, 120s, 600s]Fixed backoff tiers for delivery queue recovery
RetryConfig.attempts3Default retry attempts for generic retryAsync
RetryConfig.minDelayMs300Minimum retry delay for generic retryAsync
RetryConfig.maxDelayMs30000Maximum retry delay for generic retryAsync (30 seconds)
BackoffPolicy.factor2Exponential factor for backoff calculation (2^attempt)
OPENCLAW_NO_RESPAWN—Environment variable to disable process respawn entirely

Directory Map

Source tree for scheduling
scheduling/
├── heartbeat-runner.ts               ← Core heartbeat orchestrator (250+ lines)
├── heartbeat-active-hours.ts         ← Time-based gating for heartbeat windows
├── heartbeat-reason.ts               ← HeartbeatReasonKind classification
├── heartbeat-visibility.ts           ← Channel visibility rules
├── heartbeat-wake.ts                 ← Manual trigger / wake handlers
├── restart.ts                        ← Graceful restart controller (500+ lines)
├── process-respawn.ts                ← OS-aware process replacement
├── supervisor-markers.ts             ← launchd / systemd / schtasks detection
├── delivery-queue-recovery.ts        ← Failed delivery retry with backoff
├── backoff.ts                        ← BackoffPolicy & computeBackoff()
├── retry.ts                          ← Generic retryAsync<T>() wrapper
├── cron_expressions/                 ← Cron parsing & scheduling rules
│   └── ...
├── task_lifecycle/                   ← Scheduled task state machine
│   └── ...
└── job_queue/                        ← Priority job queue & workers
    └── ...

Deep-Dive Highlights

Core scheduling mechanisms have been extracted into dedicated focus pages for in-depth exploration. Each page covers one aspect of the scheduling pipeline with diagrams, code walks, and configuration details.

Deep-Dive Pages

Cron Expressions Focus

Cron parsing, interval resolution, and scheduling rules that drive the heartbeat timer and periodic task execution.

Extracted topic page

Task Lifecycle Focus

The scheduled task state machine: creation, deferral, execution, completion, and failure handling with audit trails.

Extracted topic page

Job Queue Focus

Priority-based job queue with worker pool management, concurrency limits, and dead-letter handling for unprocessable jobs.

Extracted topic page

Code Examples

Example: Resolving Heartbeat Summary for an Agent

import {
  resolveHeartbeatSummaryForAgent,
  areHeartbeatsEnabled,
  setHeartbeatsEnabled,
  type HeartbeatSummary,
} from "./heartbeat-runner";

// Ensure heartbeats are globally enabled
if (!areHeartbeatsEnabled()) {
  setHeartbeatsEnabled(true);
}

// Resolve full summary for a specific agent
const summary: HeartbeatSummary = resolveHeartbeatSummaryForAgent("agent-42");
console.log(summary);
// {
//   intervalMs: 60000,
//   reason: "cron-event",
//   visibility: "silent",
//   enabled: true,
// }

Example: Scheduling a Graceful Restart with Deferral

import {
  setPreRestartDeferralCheck,
  deferGatewayRestartUntilIdle,
  scheduleGatewaySigusr1Restart,
  type ScheduledRestart,
} from "./restart";

// Register a deferral check that blocks restart while messages are in flight
setPreRestartDeferralCheck(() => {
  const queueSize = getQueueSize();
  return queueSize === 0; // only allow restart when idle
});

// Defer until idle, then restart via SIGUSR1
const result: ScheduledRestart = await deferGatewayRestartUntilIdle({
  maxWaitMs: 120_000,  // wait up to 2 minutes
  reason: "deployment",
});

console.log(result);
// {
//   ok: true,
//   pid: 48291,
//   signal: "SIGUSR1",
//   delayMs: 0,
//   reason: "deployment",
//   mode: "signal",
//   coalesced: false,
//   cooldownMsApplied: 30000,
// }

Example: Recovering Failed Deliveries with Backoff

import {
  recoverPendingDeliveries,
  isPermanentDeliveryError,
  computeBackoffMs,
  type RecoverySummary,
} from "./delivery-queue-recovery";
import { computeBackoff, type BackoffPolicy } from "./backoff";
import { retryAsync, type RetryConfig } from "./retry";

// Check if an error is permanent (won't benefit from retry)
const permanent = isPermanentDeliveryError(new Error("channel_not_found"));
// true — matches one of the 14 permanent error regex patterns

// Compute backoff for a specific attempt
const policy: BackoffPolicy = {
  initialMs: 5000,
  maxMs: 600_000,
  factor: 5,
  jitter: true,
};
const delayMs = computeBackoff(policy, 3); // ~625s + jitter

// Recover all pending deliveries
const summary: RecoverySummary = await recoverPendingDeliveries({
  maxRetries: 5,
  dryRun: false,
});
console.log(summary);
// {
//   recovered: 12,
//   failed: 2,
//   skippedMaxRetries: 1,
//   deferredBackoff: 3,
// }

// Generic retry wrapper for arbitrary async operations
const data = await retryAsync(
  () => fetchWithTimeout("/api/status", 5000),
  { attempts: 3, minDelayMs: 300, maxDelayMs: 30_000, jitter: true },
);