Issue #1407. Backend:
autobot-backend/services/heartbeat_scheduler.pyandautobot-backend/api/heartbeat.py. Frontend:autobot-frontend/src/components/agents/HeartbeatPanel.vue.
All background schedulers (including HeartbeatScheduler) are enumerated in
autobot-backend/services/scheduler_registry.py— the canonical source of truth for runtime model, tick interval, and owner file. The full list is also available at runtime viaGET /api/admin/schedulers. See GH#6594.
HeartbeatPanel is a Vue component that gives operators real-time visibility into an agent's scheduled execution lifecycle. For a given agent ID it displays:
- Current configuration (enabled state, interval, max duration, last run time)
- Pending wakeup queue with priorities and reasons
- Run history (last 20 runs) with status, trigger, duration, token usage, and cost
- Per-run event timeline, expanded on click
- Controls to update configuration, trigger a manual run, and queue a wakeup
The panel subscribes to the agent:{agent_id} live-event channel and
automatically refreshes all three data sections whenever a
heartbeat_run_started or heartbeat_run_completed event arrives.
HeartbeatScheduler (asyncio tasks, one per enabled agent)
│
├─ _start_run()
│ └─ publish_live_event("agent:{id}", "heartbeat_run_started", {...})
│
├─ _invoke_agent() ← integration point for process adapter (#1406)
│
└─ _finalize_run()
└─ publish_live_event("agent:{id}", "heartbeat_run_completed", {...})
LiveEventManager (in-memory WebSocket channel router, live_event_manager.py)
└─ broadcasts to all WebSocket clients subscribed to "agent:{id}"
LiveEventService (frontend, src/services/LiveEventService.ts)
└─ useLiveEvents() composable
└─ HeartbeatPanel.vue
└─ watch(agentId, { immediate: true }) → subscribe/unsubscribe
The backend pushes events synchronously after each DB commit.
The frontend receives them over a single WebSocket connection managed by
LiveEventService and dispatches them by channel to all registered callbacks.
| Method | Purpose |
|---|---|
start() |
Load all AgentRuntimeState rows where heartbeat_enabled=True and spawn asyncio tasks |
stop() |
Cancel every active task; called on application shutdown |
enable_agent(agent_id, interval_seconds) |
Start (or restart) the loop for one agent; enforces _MIN_INTERVAL_SECONDS = 10 |
disable_agent(agent_id) |
Cancel and remove the loop for one agent |
wakeup(agent_id, context, priority, reason) |
Insert an AgentWakeupRequest row and, if the agent has no active loop, fire an ad-hoc run immediately |
_heartbeat_loopsleeps forinterval_seconds, then calls_run_once._run_oncecalls_start_run,_invoke_agent, and_finalize_runin sequence._start_runcreates aHeartbeatRunrow withstatus=RUNNING, consumes the highest-priority pendingAgentWakeupRequest(if any, its trigger overrides INTERVAL), appends arun_startedevent, and publishesheartbeat_run_startedover the live-event channel._invoke_agentwraps_execute_agentwithasyncio.wait_for(timeout). Timeout producesTIMED_OUT; exceptions produceFAILED._finalize_runupdates the run row, updatesAgentRuntimeState.last_heartbeat_at, optionally persistssession_params, appendsrun_finished, and publishesheartbeat_run_completed.
heartbeat_run_started
{
"run_id": "<uuid>",
"agent_id": "<string>",
"trigger": "interval | event | manual"
}heartbeat_run_completed
{
"run_id": "<uuid>",
"agent_id": "<string>",
"status": "completed | failed | timed_out | cancelled",
"error_message": "<string | null>",
"tokens_used": "<int | null>",
"cost_usd": "<float | null>"
}| Status | Meaning |
|---|---|
running |
In progress |
completed |
_execute_agent returned without error |
failed |
_execute_agent raised an exception |
timed_out |
Exceeded max_run_duration_seconds |
cancelled |
Task cancelled via disable_agent or stop |
| Trigger | Source |
|---|---|
interval |
Normal scheduled tick |
event |
Consumed an AgentWakeupRequest row |
manual |
POST /{agent_id}/trigger API endpoint |
_execute_agent is the stub that a process adapter (issue #1406) must bind.
It receives agent_id, state_id, and run_id, and must return a dict with
any subset of: tokens_used, cost_usd, model, provider, session_params.
Returning an empty dict is valid; the scheduler will still record the run.
All endpoints are mounted under /api/heartbeat and require a valid JWT
(Authorization: Bearer <token>).
| Method | Path | Purpose |
|---|---|---|
GET |
/{agent_id}/config |
Read config and runtime state |
PUT |
/{agent_id}/config |
Update config; syncs scheduler immediately |
PATCH |
/{agent_id}/session |
Persist session_params / current_task_id / extra |
GET |
/{agent_id}/runs |
List runs, newest first (limit/offset) |
GET |
/{agent_id}/runs/{run_id} |
Single run with full event timeline |
POST |
/{agent_id}/wakeup |
Queue a wakeup request (202 Accepted) |
GET |
/{agent_id}/wakeup |
List pending wakeup requests |
POST |
/{agent_id}/trigger |
Trigger an immediate manual run (202 Accepted) |
The scheduler instance is injected via configure_scheduler() at application
startup. Endpoints that require the scheduler return 503 if it has not been
configured.
autobot-frontend/src/components/agents/HeartbeatPanel.vue
| Ref | Type | Purpose |
|---|---|---|
agentId |
string |
Bound to the agent-ID text input |
config |
HeartbeatConfig | null |
Current runtime state from API |
runs |
HeartbeatRun[] |
Last 20 runs |
pendingWakeups |
WakeupRequest[] |
Unconsumed wakeup queue |
editEnabled / editInterval / editMaxDuration |
primitives | Editable config form values |
expandedRunId |
string | null |
Which run row is expanded to show events |
watch(agentId, (newId, oldId) => {
if (oldId) {
_liveUnsub?.()
unsubscribe(`agent:${oldId}`, _onLiveEvent)
}
if (newId) {
_liveUnsub = subscribe(`agent:${newId}`, _onLiveEvent)
}
}, { immediate: true })
onUnmounted(() => {
_liveUnsub?.()
})The { immediate: true } option means the watcher fires during component
setup, so the subscription is established as soon as agentId is non-empty
on mount. onUnmounted is the safety net that always cleans up even if
agentId is still set when the component is torn down.
_onLiveEvent calls loadData() on both heartbeat_run_started and
heartbeat_run_completed. A full three-way parallel fetch
(/config, /runs?limit=20, /wakeup) is issued each time to keep all
three panels in sync without partial state.
The component reads access_token from localStorage and sets
Authorization: Bearer <token> on every apiFetch call.
Channel format: agent:{agent_id}
Valid channel prefixes (enforced by LiveEventManager): agent, task,
workflow, global. A subscription to an unrecognised prefix is silently
rejected and logs a warning.
Clients subscribe by sending a subscription message over the WebSocket
connection to /api/live-events. The useLiveEvents composable handles
connection management and channel multiplexing transparently.
| Field | Default | Constraint | Notes |
|---|---|---|---|
heartbeat_enabled |
False |
— | Must be True for scheduler to spawn a loop |
heartbeat_interval_seconds |
300 |
>= 10 (enforced by scheduler and API) | Seconds between ticks |
max_run_duration_seconds |
600 |
>= 10 | Hard timeout per run; passed to asyncio.wait_for |
The hard floor for heartbeat_interval_seconds is _MIN_INTERVAL_SECONDS = 10
(defined in heartbeat_scheduler.py). The API schema validates ge=10 as well,
so both layers enforce it.
The default timeout _DEFAULT_MAX_DURATION_SECONDS = 600 is used when
AgentRuntimeState.max_run_duration_seconds is None.
- Confirm the agent ID entered in the panel matches the agent ID that the scheduler is using (case-sensitive UUID string).
- Open the browser DevTools Network tab and verify the WebSocket connection
to
/api/live-eventsis established (101 Switching Protocols). - Check the browser console for a log line from
HeartbeatPanel:Subscribed to live events for agent {agentId}. If absent, the watcher did not fire — ensureagentIdwas set before the component mounted and thatuseLiveEventsis imported correctly. - On the backend, check that
configure_scheduler(scheduler)was called at startup; if not, all scheduler-dependent endpoints return503.
loadData() issues three parallel apiFetch calls. If any of them fails, the
error banner will show the HTTP status and response body. Common causes:
401—access_tokeninlocalStorageis expired; re-authenticate.404— NoAgentRuntimeStaterow exists yet; the API will auto-create one on firstGET /config, so a second load will succeed.503— Scheduler not initialised; check backend startup logs.
Events are in-memory and not persisted in the LiveEventManager. If the
frontend WebSocket was disconnected when an event fired, the component will not
receive it. Clicking "Load" at any time performs a full data refresh from the
database, which will show all persisted runs regardless of whether the live
event was received.
The "Load" button is disabled while agentId is an empty string or while a
load is in progress (loading = true). Type or paste a valid UUID into the
Agent ID input and press Enter or click "Load".