How Paperclip's Heartbeat System Triggers and Manages Agent Execution
Paperclip's heartbeat system converts scheduled "runs" into live agent execution through a centralized wakeup API, scheduling suppression checks, and a multi-stage execution pipeline that handles workspace preparation, adapter invocation, and result persistence.
The heartbeat subsystem in paperclipai/paperclip serves as the bridge between user intent and autonomous agent action. Whether triggered by a UI interaction, CLI command, or automatic timer, every execution flows through server/src/services/heartbeat.ts — a server-side service that coordinates queuing, scheduling, and the full agent lifecycle.
The Wakeup API: Entry Point for All Execution
Every heartbeat run begins with a call to heartbeat.wakeup. This method is the single gateway through which three different trigger sources enqueue work:
| Trigger Source | Path to Wakeup | Implementation Location |
|---|---|---|
| UI (comments, approvals, timers) | POST /api/heartbeats/wakeup → routes/issues.ts |
server/src/routes/issues.ts:2720-2725 |
| CLI command | Direct service invocation | cli/src/commands/heartbeat-run.ts |
| Scheduled timer events | tickTimers() → startNextQueuedRunForAgent |
heartbeatService (~line 7000) |
The wakeup method performs two critical tasks. First, it inserts a row into heartbeat_runs with status queued. Second, it registers a promise in activeWakeupPromises (lines 777-795) so tests and graceful shutdown can await visibility of the new run. A companion set, activeRunExecutionPromises, tracks when execution actually begins.
// ui/src/api/heartbeats.ts
export const heartbeatsApi = {
async wakeup(companyId: string, agentId: string, payload: WakeupPayload) {
return fetch(`/api/heartbeats/${companyId}/${agentId}/wakeup`, {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
}).then(r => r.json());
},
};
// Example usage
await heartbeatsApi.wakeup("company-123", "agent-abc", {
issueId: "ISSUE-42",
reason: "issue_commented",
});
Scheduling Suppression and Safety Guards
Before any queued run begins execution, heartbeatService checks whether the environment permits scheduling. The resolveHeartbeatSchedulingSuppression function (lines 6648-6662) evaluates two suppression conditions:
export function resolveHeartbeatSchedulingSuppression(
env: Record<string, string | undefined> = process.env,
overrides: { allowWorktreeRunExecution?: boolean } = {}
): { suppressed: boolean; reason: "worktree_instance" | "database_restore_in_progress" | null } {
if (isTruthyRuntimeEnvValue(env.PAPERCLIP_IN_WORKTREE) && !overrides.allowWorktreeRunExecution) {
return { suppressed: true, reason: "worktree_instance" };
}
if (isTruthyRuntimeEnvValue(env.PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS) ||
isTruthyRuntimeEnvValue(env.PAPERCLIP_RESTORE_IN_PROGRESS)) {
return { suppressed: true, reason: "database_restore_in_progress" };
}
return { suppressed: false, reason: null };
}
Worktree instances suppress execution unless enableWorktreeRunExecution is enabled (cached 3 seconds, lines 6778-6800). Database restore operations unconditionally block scheduling to prevent data corruption. The background tickTimers loop only dequeues when suppression returns { suppressed: false }.
The Five-Stage Execution Pipeline
Once cleared through suppression checks, heartbeatService executes a standardized pipeline:
1. Configuration Resolution
resolveExecutionRunAdapterConfig (lines 9970-10125) aggregates the agent's runtime configuration, secret bindings, environment variables, and project-level environment into a unified execution context.
2. Workspace Preparation
Three coordinated calls ensure a valid working environment:
ensurePersistedExecutionWorkspaceAvailablerealizeExecutionWorkspaceensureGitWorktreeBranchCoherent
These are imported at lines 1337-1384 and handle directory creation, git state verification, and branch synchronization.
3. Adapter Invocation
getServerAdapter returns the concrete adapter (claude_local, codex_local, etc.). The adapter's execute method receives the prepared configuration and streams output to runLogStore. This is where the actual LLM interaction occurs.
4. Event Recording
Throughout execution, the service writes structured events to heartbeat_run_events with kinds like heartbeat.run.progress, heartbeat.run.queued, and heartbeat.run.completed. The publishLiveEvent helper broadcasts these via websocket to UI listeners.
5. Termination and Result Handling
On completion, mergeHeartbeatRunResultJson combines partial results and buildHeartbeatRunIssueComment posts a final summary comment. Errors are classified — transient upstream failures, max-turn exhaustion, etc. — and may trigger automatic retry with scheduled_retry status.
Concurrency Control and Runtime Limits
Per-agent and per-company limits are enforced through budgetService (lines 838-840) using the agent's heartbeat.enabled and heartbeat.intervalSec configuration. The system defaults to HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT (line 47).
When a run requires a workspace currently held by another execution, the service throws WorkspaceBusyDeferral (lines 48-55) and schedules retry with exponential backoff via computeWorkspaceBusyRetryDelayMs (lines 821-825).
Result Persistence and Cleanup
After execution terminates, heartbeat-run-summary.ts handles result formatting. It builds a concise comment (max 4096 characters) respecting HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS and related constants (lines 7-11). Each run operates within a scratch environment created by run-scratch.ts, which cleanupHeartbeatRunScratch (line 84) removes after completion.
Service Integration Architecture
The heartbeat service coordinates with multiple supporting services defined by imports at lines 0-70:
| Service | Responsibility |
|---|---|
environmentService / environmentRuntimeService |
Secret resolution and container provisioning |
executionWorkspaceService |
Workspace lifecycle management |
budgetService |
Concurrency quota enforcement |
recoveryService |
Failed run detection and retry |
taskWatchdogService |
Zombie run prevention |
issueService / issueTreeControlService |
Issue state updates and comment posting |
logActivity / publishLiveEvent |
Audit logging and real-time UI updates |
The active-run-executions set (lines 777-779) and active-run-execution-promises set (lines 780-785) together provide the hooks needed for test synchronization and graceful shutdown — ensuring no runs disappear into unobservable background execution.
CLI and Programmatic Invocation
Beyond UI triggers, developers can invoke heartbeat execution directly:
// cli/src/commands/heartbeat-run.ts
export async function runHeartbeat(args) {
const db = await getDb();
const heartbeat = heartbeatService(db);
await heartbeat.wakeup({
companyId: args.company,
agentId: args.agent,
issueId: args.issue,
reason: "manual_trigger",
});
}
The background scheduler polls for queued work in a continuous loop:
// Simplified from heartbeatService implementation
async function tickTimers() {
const { suppressed } = await getSchedulingSuppression();
if (suppressed) return;
const queued = await db.select().from(heartbeatRuns)
.where(eq(heartbeatRuns.status, "queued"))
.limit(10);
for (const run of queued) {
await startNextQueuedRunForAgent(run);
}
}
Summary
- Single entry point: All agent execution flows through
heartbeat.wakeup, creating aqueuedrow inheartbeat_runs - Safety-first scheduling: Worktree and database-restore suppression prevents execution in unsafe environments
- Five-stage pipeline: Configuration → workspace preparation → adapter execution → event recording → result handling
- Observable execution: Promise sets track active runs for testing and shutdown; events stream to UI in real-time
- Resilient retries:
WorkspaceBusyDeferraland classified errors trigger appropriate backoff and retry strategies - Tight service integration: Budget, recovery, and watchdog services collaborate to ensure reliable, bounded execution
Frequently Asked Questions
What happens if two wakeup calls target the same agent simultaneously?
Per-agent concurrency limits enforced by budgetService (lines 838-840) ensure only HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT runs execute concurrently. Additional wakeups create queued rows that tickTimers dequeues when capacity becomes available.
How does Paperclip prevent heartbeat execution during database maintenance?
The resolveHeartbeatSchedulingSuppression function checks PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS and PAPERCLIP_RESTORE_IN_PROGRESS environment variables (lines 6656-6659). When either is truthy, all scheduling halts until the flags clear.
Can I trigger heartbeat execution from outside the Paperclip UI?
Yes — the paperclipai heartbeat-run CLI command (cli/src/commands/heartbeat-run.ts) and the HTTP endpoint POST /api/heartbeats/:companyId/:agentId/wakeup (ui/src/api/heartbeats.ts) both provide direct access to the same wakeup method.
What determines which agent adapter executes a heartbeat run?
resolveExecutionRunAdapterConfig (lines 9970-10125) resolves the agent's configured adapter type (claude_local, codex_local, etc.), then getServerAdapter instantiates the appropriate implementation. The adapter's execute method receives the fully resolved configuration including secrets and environment variables.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →