Skip to content

feat(heartbeat): add skipIfNoAssignments policy to suppress idle timer wakes - #168

Closed
Logesh-waran2003 wants to merge 2 commits into
paperclipai:masterfrom
Logesh-waran2003:logesh/fix-skip-if-no-assignments
Closed

feat(heartbeat): add skipIfNoAssignments policy to suppress idle timer wakes#168
Logesh-waran2003 wants to merge 2 commits into
paperclipai:masterfrom
Logesh-waran2003:logesh/fix-skip-if-no-assignments

Conversation

@Logesh-waran2003

Copy link
Copy Markdown
Contributor

Problem

Agents with no active work still wake on every heartbeat interval. If an agent has no issues assigned (or all issues are done/cancelled), it fires a full wakeup cycle — consuming resources and polluting wakeup logs with noise.

Solution

Add a skipIfNoAssignments boolean to the heartbeat policy (defaults to false for backwards compatibility). When enabled:

  1. On a timer-triggered wake, enqueueWakeup runs a pre-flight count query for issues assigned to the agent with status in ['todo', 'in_progress', 'blocked']
  2. If the count is 0, it records a skipped request with reason "heartbeat.skipIfNoAssignments" and returns early
  3. Event-triggered wakes (assignment, on_demand, automation) are completely unaffected — the guard is scoped to source === "timer" only

Implementation

Two changes in server/src/services/heartbeat.ts:

parseHeartbeatPolicy — parse the new field:

skipIfNoAssignments: asBoolean(heartbeat.skipIfNoAssignments, false),

enqueueWakeup — pre-flight guard before processing:

if (source === "timer" && policy.skipIfNoAssignments) {
  const assignedCount = await db
    .select({ count: sql<number>`count(*)` })
    .from(issues)
    .where(
      and(
        eq(issues.companyId, agent.companyId),
        eq(issues.assigneeAgentId, agentId),
        inArray(issues.status, ["todo", "in_progress", "blocked"]),
      ),
    )
    .then(([row]) => Number(row?.count ?? 0));
  if (assignedCount === 0) {
    await writeSkippedRequest("heartbeat.skipIfNoAssignments");
    return null;
  }
}

The query hits the existing issues_company_assignee_status_idx index — no additional DB overhead at scale.

No schema migration needed — skipIfNoAssignments lives in the existing runtimeConfig JSONB column on agents.

Fixes #39

…r wakes

Agents with no active work (no issues in todo/in_progress/blocked) still
wake on every heartbeat interval, consuming resources and generating noise
in wakeup logs.

Add a `skipIfNoAssignments` boolean to the heartbeat policy. When enabled,
`enqueueWakeup` performs a pre-flight count query before processing a timer
wake — if the agent has zero assigned issues in an active status, it records
a skipped request and returns early. Event-triggered wakes (assignment,
on_demand, automation) are unaffected.

The query hits the existing `issues_company_assignee_status_idx` index so
there is no additional DB overhead at scale.

Fixes paperclipai#39

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a skipIfNoAssignments boolean to the heartbeat policy that suppresses full agent wakeup cycles when there are no active issues assigned to the agent ("todo", "in_progress", or "blocked" status). The guard is correctly scoped to source === "timer" and uses the existing issues_company_assignee_status_idx index for a lightweight pre-flight count query.

Key changes:

  • parseHeartbeatPolicy gains a new skipIfNoAssignments field (default false, backwards-compatible)
  • enqueueWakeup gains a guard block that runs a count query and writes a "skipped" wakeup request when the count is zero, returning null early
  • Non-timer wakeup sources (assignment, on_demand, automation) are completely unaffected

Issues found:

  • Critical: The skip path never updates agent.lastHeartbeatAt. The tickTimers loop uses lastHeartbeatAt as the interval baseline — lastHeartbeatAt is only updated by finalizeAgentStatus, which is only called when a run completes. Because no run starts on a skip, the baseline is never reset. Once the interval elapses for an agent with skipIfNoAssignments: true and no assignments, every subsequent tickTimers invocation will call enqueueWakeup and write a new skipped row — potentially many times per minute — which is worse log noise than the problem the PR is solving.

Confidence Score: 2/5

  • Not safe to merge — the skip path omits a lastHeartbeatAt reset, causing the interval guard in tickTimers to fire on every tick instead of once per interval, flooding agent_wakeup_requests with skipped rows.
  • The core idea and index usage are sound, but the missing lastHeartbeatAt update in the skip branch is a functional bug that directly contradicts the PR's stated goal of reducing log noise. All other existing skip paths (e.g. heartbeat.disabled) also do not reset the timestamp, but they are protected by the !policy.enabled pre-check in tickTimers which prevents enqueueWakeup from being called at all. The new skipIfNoAssignments path has no such protection in tickTimers, so the skip fires on every tick after the interval first elapses.
  • server/src/services/heartbeat.ts — specifically the enqueueWakeup skip block and the tickTimers baseline logic

Important Files Changed

Filename Overview
server/src/services/heartbeat.ts Adds skipIfNoAssignments to parseHeartbeatPolicy and a pre-flight guard in enqueueWakeup. The guard correctly checks source === "timer", hits the existing index, and is consistent with the existing skip patterns — but it does not reset lastHeartbeatAt, causing tickTimers to fire enqueueWakeup on every tick rather than once per interval, creating more log noise than the PR intends to suppress.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[tickTimers tick] --> B{policy.enabled &&\nintervalSec > 0?}
    B -- No --> Z[skip agent, no DB write]
    B -- Yes --> C{elapsedMs >=\nintervalSec * 1000?}
    C -- No --> Z
    C -- Yes --> D[enqueueWakeup\nsource = 'timer']
    D --> E{policy.enabled?}
    E -- No --> F[writeSkippedRequest\n'heartbeat.disabled'\nreturn null]
    E -- Yes --> G{policy.skipIfNoAssignments?}
    G -- No --> H[continue normal\nwakeup flow]
    G -- Yes --> I[COUNT query on issues\nwhere assigneeAgentId = agentId\nstatus IN todo/in_progress/blocked]
    I --> J{count == 0?}
    J -- No --> H
    J -- Yes --> K[writeSkippedRequest\n'heartbeat.skipIfNoAssignments'\nreturn null]
    K --> L[❌ lastHeartbeatAt NOT updated]
    L --> M[Next tickTimers tick:\nelapsedMs still >= interval\n→ enqueueWakeup called AGAIN]
    H --> N[Run queued & started]
    N --> O[finalizeAgentStatus\nupdates lastHeartbeatAt ✅]
Loading

Last reviewed commit: 4c291b0

Comment on lines +1684 to +1687
if (assignedCount === 0) {
await writeSkippedRequest("heartbeat.skipIfNoAssignments");
return null;
}

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.

Skip does not reset lastHeartbeatAt, causing per-tick flooding

writeSkippedRequest writes to agentWakeupRequests and returns null, but it never updates agent.lastHeartbeatAt. The interval timer in tickTimers (line 2234-2236) uses lastHeartbeatAt as its baseline:

const baseline = new Date(agent.lastHeartbeatAt ?? agent.createdAt).getTime();
const elapsedMs = now.getTime() - baseline;
if (elapsedMs < policy.intervalSec * 1000) continue;

Once elapsedMs >= intervalSec * 1000, every single tickTimers invocation will call enqueueWakeup for that agent, because lastHeartbeatAt is only ever updated inside finalizeAgentStatus (called on run completion). With skipIfNoAssignments: true and no assignments, no run ever starts, so lastHeartbeatAt is never refreshed.

Result: instead of one skipped row per interval, you get one skipped row per tickTimers tick — potentially dozens per minute — the exact "wakeup log noise" the PR is trying to eliminate.

The fix is to update lastHeartbeatAt on a skip (or update the tickTimers baseline) so the interval correctly resets:

if (assignedCount === 0) {
  await writeSkippedRequest("heartbeat.skipIfNoAssignments");
  // Reset the heartbeat baseline so the interval is respected
  await db
    .update(agents)
    .set({ lastHeartbeatAt: new Date(), updatedAt: new Date() })
    .where(eq(agents.id, agentId));
  return null;
}

@Logesh-waran2003

Copy link
Copy Markdown
Contributor Author

Good catch from Greptile — fixed. Added a lastHeartbeatAt reset in the skip branch so tickTimers resets the interval baseline after a skip, preventing it from firing enqueueWakeup on every subsequent tick.

nydamon added a commit to nydamon/paperclip-1 that referenced this pull request Apr 9, 2026
…ness (paperclipai#168)

Three CEO behavior fixes:

1. Handoff cooldown: CEO must not post follow-up comments on issues
   within 15 minutes of reassigning them. The generic timer heartbeat
   was posting "please prioritize" nudges immediately after handoff,
   wasting tokens while the new assignee was still picking up the work.

2. Forbidden operations table: Explicit list of operations the CEO
   must never attempt (SSH, GitHub, git push, Docker, code writing)
   with delegation instructions. Prevents the 10+ failed SSH attempts
   pattern observed on DLD-1783.

3. Gstack browse availability: Documents that the headless browser IS
   available in the container runtime, both for CEO (strategic checks
   only) and all agents (full QA testing). Prevents false "browser not
   available" claims.

Also adds handoff discipline section to CEO AGENTS.md and headless
browser documentation to the default AGENTS.md for all agents.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
sparkeros added a commit to sparkeros/paperclip-upstream that referenced this pull request Apr 16, 2026
…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.
@commitperclip

commitperclip Bot commented Jul 9, 2026

Copy link
Copy Markdown

Thanks for the contribution! This PR has been inactive for a while and has drifted from the current codebase. Closing during triage to keep the queue manageable — please reopen or resubmit if it's still relevant.

@commitperclip commitperclip Bot closed this Jul 9, 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.

feat: skip LLM invocation on timer heartbeats when agent has no assigned tasks

1 participant