feat(heartbeat): skipIfNoActionableAssignments — suppress idle timer wakes when no actionable work is queued - #3847
Conversation
…wakes when no actionable work is queued
## Problem
Agents with a heartbeat timer consume tokens on every interval even when their only assigned work is in a non-actionable state. Real example: an OpenClaw gateway agent on a 120s interval with one assigned issue in `blocked` status burned 8 Claude invocations in 2 hours making zero forward progress — the heartbeat cannot unblock the ticket, but it still runs.
## Solution
Add a `skipIfNoActionableAssignments` boolean to the heartbeat policy (default `false` for backwards compatibility). When enabled, on a timer-triggered wake, `enqueueWakeup` runs a `COUNT(*)` query for issues assigned to the agent with status in `('todo', 'backlog')`. If the count is 0, it records a skipped wakeup request with reason `heartbeat.skipIfNoActionableAssignments`, resets `lastHeartbeatAt`, and returns early. Event-triggered wakes (assignment, on-demand, automation) are unaffected.
A matching UI toggle is added to the Advanced Run Policy section on the agent configuration form.
## Relationship to paperclipai#168
This proposal is a refinement of the idea in paperclipai#168 (open, by @Logesh-waran2003). Two intentional differences:
1. **Narrower actionable status set.** paperclipai#168 skips only when *no* issues are in `('todo', 'in_progress', 'blocked')`. This PR skips unless an issue is in `('todo', 'backlog')` — `in_progress` and `blocked` are excluded. Rationale: `in_progress` means a run already holds a checkout lock on the issue (or crashed and left a stale one); in neither case does the heartbeat help productively. `blocked` is precisely the case we cannot progress.
2. **Renamed field** to `skipIfNoActionableAssignments` so the name matches the semantics — "no actionable" rather than "no" assignments.
3. **Adds a UI toggle** on the Run Policy card so users can flip the behavior without JSON editing.
We propose this PR supersedes paperclipai#168. Happy to pick up any feedback either has received.
## Verification
End-to-end verified on a Paperclip instance running this code:
- An agent with interval=120s and one assigned issue in `blocked` status produces `agent_wakeup_requests` rows with `reason=heartbeat.skipIfNoActionableAssignments`, `status=skipped` every interval, and zero `heartbeat_runs` rows.
- Flipping the same issue to `todo` causes the next timer tick to produce a real `heartbeat_runs` row.
- Reverting to `blocked` causes skips to resume.
- Unassigning the issue entirely also produces skips (existing no-assignment path).
## Files
- `server/src/services/heartbeat.ts` — policy parser + skip check in `enqueueWakeup`.
- `ui/src/components/AgentConfigForm.tsx` — new `ToggleField` in Advanced Run Policy.
Greptile SummaryThis PR adds a The PR description is missing several required sections from the PR template ( Confidence Score: 5/5Safe to merge after addressing the PR template gaps and adding screenshots; code logic is correct and backwards compatible. All findings are P2: the non-atomic writes are a low-probability edge case with benign worst-case behavior (extra skip records, no data loss), and the template/screenshot gaps are process concerns, not correctness issues. The core skip logic, status filter, companyId/agentId scoping, and UI default values are all correct. server/src/services/heartbeat.ts — the two non-atomic writes in the skip path (lines 4547–4551). Important Files Changed
Prompt To Fix All With AIThis is a comment left during a code review.
Path: ui/src/components/AgentConfigForm.tsx
Line: 907-916
Comment:
**Missing screenshots for UI change**
A new toggle is visible in the Advanced Run Policy section, but the PR description includes no before/after screenshots. Per `CONTRIBUTING.md`, UI changes require before/after screenshots (or a short video). Please add them to the PR description.
**Context Used:** CONTRIBUTING.md has a guide for a good PR message ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: server/src/services/heartbeat.ts
Line: 4547-4551
Comment:
**Non-atomic skip + timer-reset writes**
`writeSkippedRequest` and the `lastHeartbeatAt` update are two independent `await`s with no transaction. If the second write fails (e.g. transient DB error), the skipped-request row is committed but `lastHeartbeatAt` is not advanced. On the next scheduler tick the agent's interval appears to have elapsed again, the same check runs, writes another skip row, and the loop repeats — producing a burst of skip records until the update eventually succeeds.
Consider wrapping both writes in a single `db.transaction(async (tx) => { ... })` so they succeed or fail together.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "feat(heartbeat): skipIfNoActionableAssig..." | Re-trigger Greptile |
| <ToggleField | ||
| label="Skip heartbeat if no actionable assignments" | ||
| hint="On timer-triggered wakes, skip the LLM invocation entirely when the agent has no assigned issues in todo or backlog status. Issues in in_progress, in_review, blocked, done, or cancelled do not count. Prevents token burn on blocked or stalled work. Event-triggered wakes (assignments, on-demand) are unaffected." | ||
| checked={eff( | ||
| "heartbeat", | ||
| "skipIfNoActionableAssignments", | ||
| !!heartbeat.skipIfNoActionableAssignments, | ||
| )} | ||
| onChange={(v) => mark("heartbeat", "skipIfNoActionableAssignments", v)} | ||
| /> |
There was a problem hiding this comment.
Missing screenshots for UI change
A new toggle is visible in the Advanced Run Policy section, but the PR description includes no before/after screenshots. Per CONTRIBUTING.md, UI changes require before/after screenshots (or a short video). Please add them to the PR description.
Context Used: CONTRIBUTING.md has a guide for a good PR message ... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/components/AgentConfigForm.tsx
Line: 907-916
Comment:
**Missing screenshots for UI change**
A new toggle is visible in the Advanced Run Policy section, but the PR description includes no before/after screenshots. Per `CONTRIBUTING.md`, UI changes require before/after screenshots (or a short video). Please add them to the PR description.
**Context Used:** CONTRIBUTING.md has a guide for a good PR message ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.| await writeSkippedRequest("heartbeat.skipIfNoActionableAssignments"); | ||
| await db | ||
| .update(agents) | ||
| .set({ lastHeartbeatAt: new Date() }) | ||
| .where(eq(agents.id, agentId)); |
There was a problem hiding this comment.
Non-atomic skip + timer-reset writes
writeSkippedRequest and the lastHeartbeatAt update are two independent awaits with no transaction. If the second write fails (e.g. transient DB error), the skipped-request row is committed but lastHeartbeatAt is not advanced. On the next scheduler tick the agent's interval appears to have elapsed again, the same check runs, writes another skip row, and the loop repeats — producing a burst of skip records until the update eventually succeeds.
Consider wrapping both writes in a single db.transaction(async (tx) => { ... }) so they succeed or fail together.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/heartbeat.ts
Line: 4547-4551
Comment:
**Non-atomic skip + timer-reset writes**
`writeSkippedRequest` and the `lastHeartbeatAt` update are two independent `await`s with no transaction. If the second write fails (e.g. transient DB error), the skipped-request row is committed but `lastHeartbeatAt` is not advanced. On the next scheduler tick the agent's interval appears to have elapsed again, the same check runs, writes another skip row, and the loop repeats — producing a burst of skip records until the update eventually succeeds.
Consider wrapping both writes in a single `db.transaction(async (tx) => { ... })` so they succeed or fail together.
How can I resolve this? If you propose a fix, please make it concise.|
🌱 gardener · ✅
Context fit
Tree nodes referenced
No concerns. This is a narrow, per-agent skip predicate on timer-triggered heartbeats that fits the "Paperclip controls when to fire" clause of the Heartbeat Protocol and the "no hidden token burn" side of Budget Hard-Stops. Event-driven wakes (assignment, on-demand, mention) remain unaffected, so the agent still responds immediately to new work — the skip only collapses idle polling. Reviewed commit: 🌱 Posted by repo-gardener — an open-source context-aware review bot built on First-Tree. Reviews this repo against serenakeyitan/paperclip-tree, a user-maintained context tree. Not affiliated with this project's maintainers. |
|
Friendly bump — this one is mergeable, has an |
|
Thanks for the contribution! This fix already landed on master in #8347, merged 2026-06-20, which applies the same fix at this call site. Closing as already-fixed — your investigation helped confirm this was a widespread issue. |
Problem
Agents with a heartbeat timer consume tokens on every interval even when their only assigned work is in a non-actionable state. Real example: an OpenClaw gateway agent on a 120s interval with one assigned issue in
blockedstatus burned 8 Claude invocations in 2 hours making zero forward progress — the heartbeat cannot unblock the ticket, but it still runs.Solution
Add a
skipIfNoActionableAssignmentsboolean to the heartbeat policy (defaultfalsefor backwards compatibility). When enabled, on a timer-triggered wake,enqueueWakeupruns aCOUNT(*)query for issues assigned to the agent with status in('todo', 'backlog'). If the count is 0, it records a skipped wakeup request with reasonheartbeat.skipIfNoActionableAssignments, resetslastHeartbeatAt, and returns early. Event-triggered wakes (assignment, on-demand, automation) are unaffected.A matching UI toggle is added to the Advanced Run Policy section on the agent configuration form.
Relationship to #168
This proposal is a refinement of the idea in #168 (open, by @Logesh-waran2003). Three intentional differences:
('todo', 'in_progress', 'blocked'). This PR skips unless an issue is in('todo', 'backlog')—in_progressandblockedare excluded. Rationale:in_progressmeans a run already holds a checkout lock on the issue (or crashed and left a stale one); in neither case does the heartbeat help productively.blockedis precisely the case we cannot progress.skipIfNoActionableAssignmentsso the name matches the semantics — "no actionable" rather than "no" assignments.We propose this PR supersedes #168. Happy to incorporate any feedback either has received.
Verification
End-to-end verified on a Paperclip instance running this code:
blockedstatus producesagent_wakeup_requestsrows withreason=heartbeat.skipIfNoActionableAssignments,status=skippedevery interval, and zeroheartbeat_runsrows.todocauses the next timer tick to produce a realheartbeat_runsrow.blockedcauses skips to resume.Files
server/src/services/heartbeat.ts— policy parser + skip check inenqueueWakeup.ui/src/components/AgentConfigForm.tsx— newToggleFieldin Advanced Run Policy.