Skip to content

feat(heartbeat): skipIfNoActionableAssignments — suppress idle timer wakes when no actionable work is queued - #3847

Closed
sparkeros wants to merge 1 commit into
paperclipai:masterfrom
sparkeros:feat/heartbeat-skip-no-actionable-assignments
Closed

feat(heartbeat): skipIfNoActionableAssignments — suppress idle timer wakes when no actionable work is queued#3847
sparkeros wants to merge 1 commit into
paperclipai:masterfrom
sparkeros:feat/heartbeat-skip-no-actionable-assignments

Conversation

@sparkeros

Copy link
Copy Markdown
Contributor

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 #168

This proposal is a refinement of the idea in #168 (open, by @Logesh-waran2003). Three intentional differences:

  1. Narrower actionable status set. feat(heartbeat): add skipIfNoAssignments policy to suppress idle timer wakes #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 #168. Happy to incorporate 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.

…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-apps

greptile-apps Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a skipIfNoActionableAssignments boolean to the heartbeat policy that suppresses timer-triggered wakeups when an agent has no assigned issues in todo or backlog status, preventing wasted token spend on blocked/stalled work. A matching UI toggle is added to the Advanced Run Policy section. The implementation is backwards compatible (default false) and event-triggered wakes are unaffected.

The PR description is missing several required sections from the PR template (.github/PULL_REQUEST_TEMPLATE.md): there is no Thinking Path in the required blockquote format, no Risks section, no Model Used section, and no Checklist. Per CONTRIBUTING.md, every PR must follow the template. Additionally, since a UI toggle was added, before/after screenshots are required per CONTRIBUTING.md.

Confidence Score: 5/5

Safe 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

Filename Overview
server/src/services/heartbeat.ts Adds skipIfNoActionableAssignments to parseHeartbeatPolicy and enqueueWakeup; logic and scoping look correct but the two skip-path DB writes (writeSkippedRequest + lastHeartbeatAt update) are not atomic.
ui/src/components/AgentConfigForm.tsx Adds ToggleField for skipIfNoActionableAssignments in the Advanced Run Policy section; default value and onChange handler follow existing patterns correctly.
Prompt To Fix All 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.

---

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

Comment on lines +907 to +916
<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)}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +4547 to +4551
await writeSkippedRequest("heartbeat.skipIfNoActionableAssignments");
await db
.update(agents)
.set({ lastHeartbeatAt: new Date() })
.where(eq(agents.id, agentId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

@serenakeyitan

Copy link
Copy Markdown
Contributor

🌱 gardener · ✅ ALIGNED · severity: low · commit: 6258b477

What is this? repo-gardener checks whether PRs and issues fit the project's product decisions, architecture, and roadmap — not code correctness. Think of it as a product-context review layer. For code review, see Greptile/CodeRabbit.

Context fit

Area This PR Tree guidance Fit
Per-agent heartbeat option to skip timer wakes when no actionable assignments New skipIfNoActionableAssignments flag on heartbeat policy; on source === "timer", count assignee's issues in todo/backlog; if 0, log heartbeat.skipIfNoActionableAssignments, bump lastHeartbeatAt, return null — event-triggered wakes unaffected Agent Model NODE — Heartbeat Protocol: "Paperclip controls when to fire (schedule/frequency), how to fire, and what context to include." This PR adds a per-agent skip predicate that sits in the "when" layer without changing the protocol shape. ✅ Aligned
Budget / token-burn intent Hint text: "Prevents token burn on blocked or stalled work." Only applies to timer wakes; mentions, assignments, on-demand wakes all bypass it. Governance NODE — Budget Hard-Stops: "hidden token burn is not [allowed]." Skipping idle timer wakes when there's nothing to do aligns with the safe-autonomy principle. ✅ Aligned
UI surface New ToggleField on AgentConfigForm with descriptive hint covering which statuses count and which wake types are unaffected Frontend NODE — agent config form lives in AgentConfigForm; React Query + centralized API patterns unchanged ✅ Aligned
Single-assignee scoping Count query filters by eq(issues.assigneeAgentId, agentId) + in ["todo", "backlog"] Task-system NODE — Single-Assignee Model; Issue Status Workflow fixed enum ✅ Aligned
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: 6258b477 · Tree snapshot: ddfe6b6 · Commands: @gardener re-review · @gardener pause · @gardener ignore

🌱 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.

@sparkeros

Copy link
Copy Markdown
Contributor Author

Friendly bump — this one is mergeable, has an ALIGNED repo-gardener verdict from a few weeks back, and is a narrow opt-in flag (default off) on a per-agent setting. Happy to address any review feedback. Would love a maintainer look when there's a window.

@commitperclip

commitperclip Bot commented Jul 24, 2026

Copy link
Copy Markdown

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.

@commitperclip commitperclip Bot closed this Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants