feat: configurable heartbeat session

This commit is contained in:
Zach Knickerbocker
2026-01-19 15:42:07 -05:00
committed by Peter Steinberger
parent db61451c67
commit 7725dd6795
8 changed files with 230 additions and 298 deletions

View File

@@ -334,6 +334,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble).
- `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to` - `agents.defaults.heartbeat.to`
- `agents.defaults.heartbeat.session`
- `agents.list[].heartbeat.*` (per-agent overrides) - `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false) - `web.enabled` (disable channel startup when false)

View File

@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
When validation fails: When validation fails:
- The Gateway does not boot. - The Gateway does not boot.
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`). - Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
- Run `clawdbot doctor` to see the exact issues. - Run `clawdbot doctor` to see the exact issues.
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. - Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
- `alias` (optional model shortcut, e.g. `/opus`). - `alias` (optional model shortcut, e.g. `/opus`).
- `params` (optional provider-specific API params passed through to the model request). - `params` (optional provider-specific API params passed through to the model request).
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the models defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. `params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the models defaults and need a change.
Example: Example:
@@ -1569,7 +1569,7 @@ Example:
} }
``` ```
#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning) #### `agents.defaults.contextPruning` (tool-result pruning)
`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. `agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
It does **not** modify the session history on disk (`*.jsonl` remains complete). It does **not** modify the session history on disk (`*.jsonl` remains complete).
@@ -1580,9 +1580,11 @@ High level:
- Never touches user/assistant messages. - Never touches user/assistant messages.
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned). - Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
- Protects the bootstrap prefix (nothing before the first user message is pruned). - Protects the bootstrap prefix (nothing before the first user message is pruned).
- Mode: - Modes:
- `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`. - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
When it runs, it uses the same soft-trim + hard-clear behavior as before. Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
theres enough prunable tool-result bulk (`minPrunableToolChars`).
- `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
Soft vs hard pruning (what changes in the context sent to the LLM): Soft vs hard pruning (what changes in the context sent to the LLM):
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle. - **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
@@ -1596,41 +1598,44 @@ Notes / current limitations:
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now. - Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens. - The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
- If the session doesnt contain at least `keepLastAssistants` assistant messages yet, pruning is skipped. - If the session doesnt contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models). - In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again.
- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`.
Default (off, unless Anthropic auth profiles are detected): Default (adaptive):
```json5
{
agents: { defaults: { contextPruning: { mode: "adaptive" } } }
}
```
To disable:
```json5 ```json5
{ {
agents: { defaults: { contextPruning: { mode: "off" } } } agents: { defaults: { contextPruning: { mode: "off" } } }
} }
``` ```
Enable TTL-aware pruning: Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
- `keepLastAssistants`: `3`
- `softTrimRatio`: `0.3` (adaptive only)
- `hardClearRatio`: `0.5` (adaptive only)
- `minPrunableToolChars`: `50000` (adaptive only)
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only)
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
Example (aggressive, minimal):
```json5 ```json5
{ {
agents: { defaults: { contextPruning: { mode: "cache-ttl" } } } agents: { defaults: { contextPruning: { mode: "aggressive" } } }
} }
``` ```
Defaults (when `mode` is `"cache-ttl"`): Example (adaptive tuned):
- `ttl`: `"5m"`
- `keepLastAssistants`: `3`
- `softTrimRatio`: `0.3`
- `hardClearRatio`: `0.5`
- `minPrunableToolChars`: `50000`
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }`
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
Example (cache-ttl tuned):
```json5 ```json5
{ {
agents: { agents: {
defaults: { defaults: {
contextPruning: { contextPruning: {
mode: "cache-ttl", mode: "adaptive",
ttl: "5m",
keepLastAssistants: 3, keepLastAssistants: 3,
softTrimRatio: 0.3, softTrimRatio: 0.3,
hardClearRatio: 0.5, hardClearRatio: 0.5,
@@ -1737,12 +1742,9 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`30m`. Set `0m` to disable. `30m`. Set `0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`). - `model`: optional override model for heartbeat runs (`provider/model`).
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`. - `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
- `activeHours`: optional local-time window that controls when heartbeats run. - `session`: optional session key to control which session the heartbeat runs in. Default: `main`.
- `start`: start time (HH:MM, 24h). Inclusive.
- `end`: end time (HH:MM, 24h). Exclusive. Use `"24:00"` for end-of-day.
- `timezone`: `"user"` (default), `"local"`, or an IANA timezone id.
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `msteams`, `signal`, `imessage`, `none`). Default: `last`.
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read. - `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
@@ -1773,7 +1775,6 @@ Note: `applyPatch` is only under `tools.exec`.
- `tools.web.fetch.maxChars` (default 50000) - `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30) - `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15) - `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.maxRedirects` (default 3)
- `tools.web.fetch.userAgent` (optional override) - `tools.web.fetch.userAgent` (optional override)
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only) - `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set) - `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
@@ -1840,7 +1841,7 @@ Example:
`agents.defaults.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the callers model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the callers model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 8) - `maxConcurrent`: max concurrent sub-agent runs (default 1)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
@@ -1974,7 +1975,7 @@ Notes:
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 4. per session key at a time). Default: 1.
### `agents.defaults.sandbox` ### `agents.defaults.sandbox`
@@ -2452,9 +2453,6 @@ Controls session scoping, reset policy, reset triggers, and where the session st
dm: { mode: "idle", idleMinutes: 240 }, dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 } group: { mode: "idle", idleMinutes: 120 }
}, },
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json // Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating: // You can override with {agentId} templating:
@@ -2490,7 +2488,7 @@ Fields:
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`. - `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`). - `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
@@ -2617,13 +2615,10 @@ Defaults:
// noSandbox: false, // noSandbox: false,
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost // attachOnly: false, // set true when tunneling a remote CDP to localhost
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
} }
} }
``` ```
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
### `ui` (Appearance) ### `ui` (Appearance)
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint). Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
@@ -2647,13 +2642,6 @@ Defaults:
- bind: `loopback` - bind: `loopback`
- port: `18789` (single port for WS + HTTP) - port: `18789` (single port for WS + HTTP)
Bind modes:
- `loopback`: `127.0.0.1` (local-only)
- `lan`: `0.0.0.0` (all interfaces)
- `tailnet`: Tailscale IPv4 address (100.64.0.0/10)
- `auto`: prefer loopback, fall back to LAN if loopback cannot bind
- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable
```json5 ```json5
{ {
gateway: { gateway: {
@@ -2684,15 +2672,14 @@ Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- The onboarding wizard generates a gateway token by default (even on loopback). - The onboarding wizard generates a gateway token by default (even on loopback).
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing). - `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
@@ -2701,9 +2688,6 @@ Auth and Tailscale:
`true`, Serve requests do not need a token/password; set `false` to require `true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`. auth mode is not `password`.
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
@@ -2712,7 +2696,6 @@ Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256).
macOS app behavior: macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. - Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
@@ -2726,36 +2709,12 @@ macOS app behavior:
remote: { remote: {
url: "ws://gateway.tailnet:18789", url: "ws://gateway.tailnet:18789",
token: "your-token", token: "your-token",
password: "your-password", password: "your-password"
tlsFingerprint: "sha256:ab12cd34..."
} }
} }
} }
``` ```
### `gateway.nodes` (Node command allowlist)
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
**declare** a command and have it **allowed** by the Gateway to run it.
Use this section to extend or deny commands:
```json5
{
gateway: {
nodes: {
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
denyCommands: ["sms.send"] // block a command even if declared
}
}
}
```
Notes:
- `allowCommands` extends the built-in per-platform defaults.
- `denyCommands` always wins (even if the node claims the command).
- `node.invoke` rejects commands that are not declared by the node.
### `gateway.reload` (Config hot reload) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
@@ -3003,7 +2962,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNSSD) ### `discovery.wideArea` (Wide-Area Bonjour / unicast DNSSD)
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with: To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended) - a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
@@ -3033,9 +2992,6 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
| `{{To}}` | Destination identifier | | `{{To}}` | Destination identifier |
| `{{MessageSid}}` | Channel message id (when available) | | `{{MessageSid}}` | Channel message id (when available) |
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
| `{{ReplyToId}}` | Reply-to message id (when available) |
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
| `{{SessionId}}` | Current session UUID | | `{{SessionId}}` | Current session UUID |
| `{{IsNewSession}}` | `"true"` when a new session was created | | `{{IsNewSession}}` | `"true"` when a new session was created |
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |

View File

@@ -127,18 +127,26 @@ Example: two agents, only the second agent runs heartbeats.
- `every`: heartbeat interval (duration string; default unit = minutes). - `every`: heartbeat interval (duration string; default unit = minutes).
- `model`: optional model override for heartbeat runs (`provider/model`). - `model`: optional model override for heartbeat runs (`provider/model`).
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). - `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
- `session`: optional session key for heartbeat runs.
- `main` (default): agent main session.
- Explicit session key (copy from `clawdbot sessions --json` or the [sessions CLI](/cli/sessions)).
- Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups).
- `target`: - `target`:
- `last` (default): deliver to the last used external channel. - `last` (default): deliver to the last used external channel.
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`. - explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`.
- `none`: run the heartbeat but **do not deliver** externally. - `none`: run the heartbeat but **do not deliver** externally.
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram, etc.). - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id).
- `prompt`: overrides the default prompt body (not merged). - `prompt`: overrides the default prompt body (not merged).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
## Delivery behavior ## Delivery behavior
- Heartbeats run in each agents **main session** (`agent:<id>:<mainKey>`), or `global` - Heartbeats run in the agents main session by default (`agent:<id>:<mainKey>`),
when `session.scope = "global"`. or `global` when `session.scope = "global"`. Set `session` to override to a
specific channel session (Discord/WhatsApp/etc.).
- `session` only affects the run context; delivery is controlled by `target` and `to`.
- To deliver to a specific channel/recipient, set `target` + `to`. With
`target: "last"`, delivery uses the last external channel for that session.
- If the main queue is busy, the heartbeat is skipped and retried later. - If the main queue is busy, the heartbeat is skipped and retried later.
- If `target` resolves to no external destination, the run still happens but no - If `target` resolves to no external destination, the run still happens but no
outbound message is sent. outbound message is sent.

View File

@@ -173,6 +173,8 @@ export type AgentDefaultsConfig = {
}; };
/** Heartbeat model override (provider/model). */ /** Heartbeat model override (provider/model). */
model?: string; model?: string;
/** Session key for heartbeat runs ("main" or explicit session key). */
session?: string;
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
target?: target?:
| "last" | "last"

View File

@@ -20,6 +20,7 @@ export const HeartbeatSchema = z
.strict() .strict()
.optional(), .optional(),
model: z.string().optional(), model: z.string().optional(),
session: z.string().optional(),
includeReasoning: z.boolean().optional(), includeReasoning: z.boolean().optional(),
target: z target: z
.union([ .union([

View File

@@ -41,7 +41,6 @@ describe("resolveHeartbeatIntervalMs", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
ackMaxChars: 0, ackMaxChars: 0,
}, },
}, },
@@ -58,6 +57,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -102,7 +102,6 @@ describe("resolveHeartbeatIntervalMs", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
}, },
}, },
}, },
@@ -118,6 +117,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -164,7 +164,6 @@ describe("resolveHeartbeatIntervalMs", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
}, },
}, },
}, },
@@ -180,6 +179,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: originalUpdatedAt, updatedAt: originalUpdatedAt,
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -231,7 +231,7 @@ describe("resolveHeartbeatIntervalMs", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp" },
}, },
}, },
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
@@ -246,6 +246,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -291,7 +292,7 @@ describe("resolveHeartbeatIntervalMs", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "telegram", to: "123456" }, heartbeat: { every: "5m", target: "telegram" },
}, },
}, },
channels: { telegram: { botToken: "test-bot-token-123" } }, channels: { telegram: { botToken: "test-bot-token-123" } },
@@ -306,6 +307,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "telegram",
lastProvider: "telegram", lastProvider: "telegram",
lastTo: "123456", lastTo: "123456",
}, },
@@ -357,7 +359,7 @@ describe("resolveHeartbeatIntervalMs", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "telegram", to: "123456" }, heartbeat: { every: "5m", target: "telegram" },
}, },
}, },
channels: { channels: {
@@ -378,6 +380,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "telegram",
lastProvider: "telegram", lastProvider: "telegram",
lastTo: "123456", lastTo: "123456",
}, },

View File

@@ -11,6 +11,7 @@ import {
resolveMainSessionKey, resolveMainSessionKey,
resolveStorePath, resolveStorePath,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
import { import {
isHeartbeatEnabledForAgent, isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs, resolveHeartbeatIntervalMs,
@@ -332,7 +333,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp" },
}, },
}, },
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
@@ -395,7 +396,7 @@ describe("runHeartbeatOnce", () => {
{ id: "main", default: true }, { id: "main", default: true },
{ {
id: "ops", id: "ops",
heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" }, heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
}, },
], ],
}, },
@@ -451,6 +452,86 @@ describe("runHeartbeatOnce", () => {
} }
}); });
it("runs heartbeats in the explicit session key when configured", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
const groupId = "120363401234567890@g.us";
const cfg: ClawdbotConfig = {
agents: {
defaults: {
heartbeat: {
every: "5m",
target: "last",
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const mainSessionKey = resolveMainSessionKey(cfg);
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
const groupSessionKey = buildAgentPeerSessionKey({
agentId,
channel: "whatsapp",
peerKind: "group",
peerId: groupId,
});
cfg.agents?.defaults?.heartbeat && (cfg.agents.defaults.heartbeat.session = groupSessionKey);
await fs.writeFile(
storePath,
JSON.stringify(
{
[mainSessionKey]: {
sessionId: "sid-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
[groupSessionKey]: {
sessionId: "sid-group",
updatedAt: Date.now() + 10_000,
lastChannel: "whatsapp",
lastTo: groupId,
},
},
null,
2,
),
);
replySpy.mockResolvedValue([{ text: "Group alert" }]);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
await runHeartbeatOnce({
cfg,
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expect(sendWhatsApp).toHaveBeenCalledWith(groupId, "Group alert", expect.any(Object));
expect(replySpy).toHaveBeenCalledWith(
expect.objectContaining({ SessionKey: groupSessionKey }),
{ isHeartbeat: true },
cfg,
);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("suppresses duplicate heartbeat payloads within 24h", async () => { it("suppresses duplicate heartbeat payloads within 24h", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json"); const storePath = path.join(tmpDir, "sessions.json");
@@ -459,7 +540,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp" },
}, },
}, },
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
@@ -517,7 +598,6 @@ describe("runHeartbeatOnce", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
includeReasoning: true, includeReasoning: true,
}, },
}, },
@@ -534,6 +614,7 @@ describe("runHeartbeatOnce", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -588,7 +669,6 @@ describe("runHeartbeatOnce", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
includeReasoning: true, includeReasoning: true,
}, },
}, },
@@ -605,6 +685,7 @@ describe("runHeartbeatOnce", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -672,6 +753,7 @@ describe("runHeartbeatOnce", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },

View File

@@ -1,5 +1,4 @@
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveUserTimezone } from "../agents/date-time.js";
import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
import { import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS, DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
@@ -16,6 +15,8 @@ import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import {
loadSessionStore, loadSessionStore,
canonicalizeMainSessionAlias,
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey, resolveAgentMainSessionKey,
resolveStorePath, resolveStorePath,
saveSessionStore, saveSessionStore,
@@ -25,9 +26,8 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js"; import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js";
import { import {
type HeartbeatRunResult, type HeartbeatRunResult,
@@ -70,94 +70,6 @@ export type HeartbeatSummary = {
}; };
const DEFAULT_HEARTBEAT_TARGET = "last"; const DEFAULT_HEARTBEAT_TARGET = "last";
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "user") {
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
}
if (trimmed === "local") {
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
return resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
}
}
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr);
const minute = Number(minuteStr);
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
if (hour === 24) {
if (!opts.allow24 || minute !== 0) return null;
return 24 * 60;
}
return hour * 60 + minute;
}
function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(nowMs));
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
const hour = Number(map.hour);
const minute = Number(map.minute);
if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
return hour * 60 + minute;
} catch {
return null;
}
}
function isWithinActiveHours(
cfg: ClawdbotConfig,
heartbeat?: HeartbeatConfig,
nowMs?: number,
): boolean {
const active = heartbeat?.activeHours;
if (!active) return true;
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
if (startMin === null || endMin === null) return true;
if (startMin === endMin) return true;
const timeZone = resolveActiveHoursTimezone(cfg, active.timezone);
const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone);
if (currentMin === null) return true;
if (endMin > startMin) {
return currentMin >= startMin && currentMin < endMin;
}
return currentMin >= startMin || currentMin < endMin;
}
type HeartbeatAgentState = {
agentId: string;
heartbeat?: HeartbeatConfig;
intervalMs: number;
lastRunMs?: number;
nextDueMs: number;
};
export type HeartbeatRunner = {
stop: () => void;
updateConfig: (cfg: ClawdbotConfig) => void;
};
function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) { function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) {
const list = cfg.agents?.list ?? []; const list = cfg.agents?.list ?? [];
@@ -286,17 +198,53 @@ function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatC
); );
} }
function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) { function resolveHeartbeatSession(
cfg: ClawdbotConfig,
agentId?: string,
heartbeat?: HeartbeatConfig,
) {
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender"; const scope = sessionCfg?.scope ?? "per-sender";
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
const sessionKey = const mainSessionKey =
scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId }); scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId; const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId }); const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const entry = store[sessionKey]; const mainEntry = store[mainSessionKey];
return { sessionKey, storePath, store, entry };
if (scope === "global") {
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
}
const trimmed = heartbeat?.session?.trim() ?? "";
if (!trimmed) {
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
}
const normalized = trimmed.toLowerCase();
if (normalized === "main" || normalized === "global") {
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
}
const candidate = toAgentStoreSessionKey({
agentId: resolvedAgentId,
requestKey: trimmed,
mainKey: cfg.session?.mainKey,
});
const canonical = canonicalizeMainSessionAlias({
cfg,
agentId: resolvedAgentId,
sessionKey: candidate,
});
if (canonical !== "global") {
const sessionAgentId = resolveAgentIdFromSessionKey(canonical);
if (sessionAgentId === normalizeAgentId(resolvedAgentId)) {
return { sessionKey: canonical, storePath, store, entry: store[canonical] };
}
}
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
} }
function resolveHeartbeatReplyPayload( function resolveHeartbeatReplyPayload(
@@ -417,17 +365,13 @@ export async function runHeartbeatOnce(opts: {
return { status: "skipped", reason: "disabled" }; return { status: "skipped", reason: "disabled" };
} }
const startedAt = opts.deps?.nowMs?.() ?? Date.now(); const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main");
if (!isWithinActiveHours(cfg, heartbeat, startedAt)) {
return { status: "skipped", reason: "quiet-hours" };
}
const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main);
if (queueSize > 0) { if (queueSize > 0) {
return { status: "skipped", reason: "requests-in-flight" }; return { status: "skipped", reason: "requests-in-flight" };
} }
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId); const startedAt = opts.deps?.nowMs?.() ?? Date.now();
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
const previousUpdatedAt = entry?.updatedAt; const previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
const lastChannel = delivery.lastChannel; const lastChannel = delivery.lastChannel;
@@ -632,97 +576,24 @@ export function startHeartbeatRunner(opts: {
cfg?: ClawdbotConfig; cfg?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
runOnce?: typeof runHeartbeatOnce; }) {
}): HeartbeatRunner { const cfg = opts.cfg ?? loadConfig();
const heartbeatAgents = resolveHeartbeatAgents(cfg);
const intervals = heartbeatAgents
.map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat))
.filter((value): value is number => typeof value === "number");
const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null;
if (!intervalMs) {
log.info("heartbeat: disabled", { enabled: false });
}
const runtime = opts.runtime ?? defaultRuntime; const runtime = opts.runtime ?? defaultRuntime;
const runOnce = opts.runOnce ?? runHeartbeatOnce; const lastRunByAgent = new Map<string, number>();
const state = {
cfg: opts.cfg ?? loadConfig(),
runtime,
agents: new Map<string, HeartbeatAgentState>(),
timer: null as NodeJS.Timeout | null,
stopped: false,
};
let initialized = false;
const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => {
if (typeof prevState?.lastRunMs === "number") {
return prevState.lastRunMs + intervalMs;
}
if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) {
return prevState.nextDueMs;
}
return now + intervalMs;
};
const scheduleNext = () => {
if (state.stopped) return;
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (state.agents.size === 0) return;
const now = Date.now();
let nextDue = Number.POSITIVE_INFINITY;
for (const agent of state.agents.values()) {
if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs;
}
if (!Number.isFinite(nextDue)) return;
const delay = Math.max(0, nextDue - now);
state.timer = setTimeout(() => {
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
}, delay);
state.timer.unref?.();
};
const updateConfig = (cfg: ClawdbotConfig) => {
if (state.stopped) return;
const now = Date.now();
const prevAgents = state.agents;
const prevEnabled = prevAgents.size > 0;
const nextAgents = new Map<string, HeartbeatAgentState>();
const intervals: number[] = [];
for (const agent of resolveHeartbeatAgents(cfg)) {
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
if (!intervalMs) continue;
intervals.push(intervalMs);
const prevState = prevAgents.get(agent.agentId);
const nextDueMs = resolveNextDue(now, intervalMs, prevState);
nextAgents.set(agent.agentId, {
agentId: agent.agentId,
heartbeat: agent.heartbeat,
intervalMs,
lastRunMs: prevState?.lastRunMs,
nextDueMs,
});
}
state.cfg = cfg;
state.agents = nextAgents;
const nextEnabled = nextAgents.size > 0;
if (!initialized) {
if (!nextEnabled) {
log.info("heartbeat: disabled", { enabled: false });
} else {
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
}
initialized = true;
} else if (prevEnabled !== nextEnabled) {
if (!nextEnabled) {
log.info("heartbeat: disabled", { enabled: false });
} else {
log.info("heartbeat: started", { intervalMs: Math.min(...intervals) });
}
}
scheduleNext();
};
const run: HeartbeatWakeHandler = async (params) => { const run: HeartbeatWakeHandler = async (params) => {
if (!heartbeatsEnabled) { if (!heartbeatsEnabled) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
} }
if (state.agents.size === 0) { if (heartbeatAgents.length === 0) {
return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult;
} }
@@ -732,44 +603,52 @@ export function startHeartbeatRunner(opts: {
const now = startedAt; const now = startedAt;
let ran = false; let ran = false;
for (const agent of state.agents.values()) { for (const agent of heartbeatAgents) {
if (isInterval && now < agent.nextDueMs) { const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat);
if (!agentIntervalMs) continue;
const lastRun = lastRunByAgent.get(agent.agentId);
if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) {
continue; continue;
} }
const res = await runOnce({ const res = await runHeartbeatOnce({
cfg: state.cfg, cfg,
agentId: agent.agentId, agentId: agent.agentId,
heartbeat: agent.heartbeat, heartbeat: agent.heartbeat,
reason, reason,
deps: { runtime: state.runtime }, deps: { runtime },
}); });
if (res.status === "skipped" && res.reason === "requests-in-flight") { if (res.status === "skipped" && res.reason === "requests-in-flight") {
return res; return res;
} }
if (res.status !== "skipped" || res.reason !== "disabled") { if (res.status !== "skipped" || res.reason !== "disabled") {
agent.lastRunMs = now; lastRunByAgent.set(agent.agentId, now);
agent.nextDueMs = now + agent.intervalMs;
} }
if (res.status === "ran") ran = true; if (res.status === "ran") ran = true;
} }
scheduleNext();
if (ran) return { status: "ran", durationMs: Date.now() - startedAt }; if (ran) return { status: "ran", durationMs: Date.now() - startedAt };
return { status: "skipped", reason: isInterval ? "not-due" : "disabled" }; return { status: "skipped", reason: isInterval ? "not-due" : "disabled" };
}; };
setHeartbeatWakeHandler(async (params) => run({ reason: params.reason })); setHeartbeatWakeHandler(async (params) => run({ reason: params.reason }));
updateConfig(state.cfg);
let timer: NodeJS.Timeout | null = null;
if (intervalMs) {
timer = setInterval(() => {
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });
}, intervalMs);
timer.unref?.();
log.info("heartbeat: started", { intervalMs });
}
const cleanup = () => { const cleanup = () => {
state.stopped = true;
setHeartbeatWakeHandler(null); setHeartbeatWakeHandler(null);
if (state.timer) clearTimeout(state.timer); if (timer) clearInterval(timer);
state.timer = null; timer = null;
}; };
opts.abortSignal?.addEventListener("abort", cleanup, { once: true }); opts.abortSignal?.addEventListener("abort", cleanup, { once: true });
return { stop: cleanup, updateConfig }; return { stop: cleanup };
} }