Paperclip
Paperclip’s heartbeat idempotency key did not actually deduplicate wakeups
An open Paperclip patch says repeated wakeups shared an idempotency key but still created separate pending or active runs. The proposed server-side reservation makes one database row own the key and returns the existing run to followers instead of launching duplicate agent work.
paperclipai/paperclip PR #10412 is the inspected primary source: “fix(heartbeat): make agent wakeup idempotency keys actually deduplicate.” The two related records below were inspected as supporting context, not treated as independent confirmation.
The facts
- paperclipai/paperclip PR #10412 was created 2026-07-28T23:55:42Z and was open at inspection; its title is “fix(heartbeat): make agent wakeup idempotency keys actually deduplicate.” - paperclipai/paperclip issue #10404 was created 2026-07-28T21:52:12Z and was open at inspection; its title is “Liveness classifier has no invariant for status=blocked with zero blocker relations (silent, unwakeable dead state).” - paperclipai/paperclip issue #10406 was created 2026-07-28T22:14:17Z and was open at inspection; its title is “Creating a blocked issue does not wake its unblock owner.” - The repository reported 75010 stars, 4976 open issues, default branch master, and last push 2026-07-28T22:50:35Z. - The latest tagged-release baseline checked was v2026.722.0 published 2026-07-22T23:05:41Z.
What changed
paperclipai/paperclip PR #10412 reports: Thinking Path - Paperclip is the open source app people use to manage AI agents for work - Every agent invocation goes through the heartbeat wake path: enqueueWakeup writes an agent_wakeup_requests row and queues the run that executes it - That table has an idempotency_key column, and callers across recovery, issue monitors, task watchdogs, routable-blocked and the plugin host pass one — but nothing ever enforced or read it: there was no unique index, and enqueueWakeup only wrote the value - So "wake this agent at most once for this event" was not something the platform could actually promise; callers that needed it had to hand-roll a lookup, and two concurrent enqueues could still each miss the other's uncommitted row and queue two full adapter sessions for the same event - This pull request makes the key enforceable: a partial unique index plus a lookup and conflict-tolerant insert in the enqueue path - The benefit is that duplicate wakes stop at the database instead of
paperclipai/paperclip issue #10404 reports: Summary An issue can end up with status: "blocked" and an **empty** blockedByIssueIds (no blocker relations at all). classifyIssueGraphLiveness() in server/src/services/recovery/issue-graph-liveness.ts detects liveness problems by walking blocker relations via firstBlockedChainFinding(); when there are none to walk, the function returns immediately with no finding. Unlike the existing IssueLivenessState values (blocked_by_unassigned_issue, blocked_by_assigned_backlog_issue, blocked_by_uninvokable_assignee, blocked_by_cancelled_issue, invalid_review_participant, in_review_without_action_path), there is no state modeling "blocked with nothing recorded to unblock it." The issue is structurally invisible to the reconciler and can never wake again through the normal blockers-resolved path. This is a different failure mode from #6523 (which is about wakes firing *repeatedly* on a bad blocked/empty-blockers state). Here, zero wakes ever fire for this class — it's a silent dead end, not a loop. We hit this on a self-hosted instance during a board sweep: six occurrences in about a day, with a mix of underlying causes (waiting on a human action, harness bookkeeping left over from a cancelled run, and a real dependency the agent identified correctly but
paperclipai/paperclip issue #10406 reports: Pre-submission checklist [x] I have searched existing open and closed issues and this is not a duplicate. [x] I can reproduce this on current master. [x] I confirmed the failure is in Paperclip core, not an adapter, provider, or local configuration. What happened? Creating an issue directly in blocked status with an agent-owned unblockDescriptor returns 201 but does not record a routable blocked transition and does not request a wake for the named owner. This affects both: POST /api/companies/:companyId/issues POST /api/issues/:id/children The create service inserts the supplied blocked status without setting blockedTransitionAt. The routes then only run assignment wake logic; unlike the working PATCH transition path, they never call deliverAgentUnblockNotification. Expected behavior A newly created blocked issue with an agent owner should: 1. Persist a non-null blockedTransitionAt. 2. Mint one issue_unblock_requested wake for that owner using issue-unblock:: as the idempotency key. 3. Persist blockedOwnerNotifiedAt after the wake request. 4. Not mint another wake when a root create is replayed through the same create idempotency
Why it matters
Idempotency that exists only in request metadata is ceremonial. Wakeups need one durable ownership decision before execution, especially when comments, schedules and recovery monitors can race. This is an operator boundary: persisted state, execution ownership and visible controls must describe the same event. A workaround that broadens access, deletes state or hides the symptom would trade a visible defect for an unmeasured one.
Current
The primary record was open when captured on 2026-07-29. The release baseline was v2026.722.0 published 2026-07-22T23:05:41Z. Current repository metadata, pull requests, issues, release records, Hacker News discovery results and the rendered ClawCharts row were checked. ClawCharts selected the subject; it did not prove the claim.
Evidence
The primary URL is https://github.com/paperclipai/paperclip/pull/10412. Supporting records are https://github.com/paperclipai/paperclip/issues/10404 and https://github.com/paperclipai/paperclip/issues/10406. Their source bodies, timestamps and states were captured in the daily evidence bundle. Test counts, reproductions and deployment observations remain attributed to their authors unless explicitly identified as independently rerun.
Source boundary
Open work is described as open, closed work as closed, and operator reports as reports. A pull request is evidence of proposed or reviewed direction, not proof of a shipped release. Issue closure would not by itself prove a deployed fix. Search residue, package mirrors and historically published source spines were excluded.
Operator take
Verify deduplication at the database boundary with concurrent requests, then keep liveness monitoring separate from the ownership decision. Preserve a before-state receipt, make the smallest reversible change, and verify the original failure independently after intervention.
Caveat
Public project records are mutable. Status, scope and evidence can change after publication. This brief records the inspected state and does not authorize changes to a reader’s deployment.
Source inspected; source state, environment and release boundary remain explicit.