Preventing Double Work with Paperclip AI Task Checkout and Locking: A Complete Guide

Paperclip AI prevents duplicate work by persisting a exclusive checkout lock in the database, allowing only one run (agent or board) to own an issue at a time while automatically falling back to board-level coordination when conflicts occur.

The Paperclip AI repository (paperclipai/paperclip) implements a robust concurrency control system that eliminates race conditions when multiple execution contexts attempt to work the same issue. By storing lock state directly in the issues table and enforcing ownership through database-level validation, the system guarantees exactly-once execution semantics across process restarts and distributed deployments.

How Database-Level Locking Prevents Duplicate Work

At the core of Paperclip AI’s concurrency model are two columns in the issues table: checkoutRunId and executionRunId. These nullable string fields track which run currently owns the checkout lock and which run is actively executing the issue, respectively.

The lock validation logic resides in server/src/services/issues.ts within the sameRunLock helper function:

function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
  if (actorRunId) return checkoutRunId === actorRunId;
  return checkoutRunId == null;
}

This function implements the ownership semantics: an actor can acquire the lock only if the stored checkoutRunId matches its own run ID, or if no lock exists (null). When an agent or board attempts to checkout an issue via POST /api/issues/:id/checkout, the server validates the request against this rule before writing the lock.

Implementing the Checkout Endpoint in server/src/services/issues.ts

The checkout endpoint handler demonstrates the complete lock acquisition flow. When a request arrives, the server extracts the actorRunId from the request context and queries the current issue state:

// server/src/services/issues.ts – checkout endpoint handler
export const checkout = async (req, res) => {
  const { id } = req.params;
  const actorRunId = req.actor?.runId ?? null;

  const issue = await db.selectIssue(id);
  if (!sameRunLock(issue.checkoutRunId, actorRunId)) {
    return res.status(409).json({ error: 'Issue already checked out' });
  }

  await db.update(issue.id, { checkoutRunId: actorRunId });
  await activity.log('issue.checkout_lock_adopted', {
    issueId: issue.id,
    checkoutRunId: actorRunId,
  });
  return res.json(issue);
};

If validation fails—meaning another run already holds the lock—the server immediately returns HTTP 409 Conflict, preventing the double-work scenario. On success, the system persists the new checkoutRunId and emits an issue.checkout_lock_adopted activity event for observability.

Handling Lock Conflicts with Agent-to-Board Fallback

Production deployments must handle transient lock failures gracefully. The end-to-end test suite in tests/e2e/signoff-policy.spec.ts demonstrates the recommended client-side pattern: attempt an agent checkout first, then fall back to a board checkout if the lock is unavailable.

const checkoutRes = await agent.request.post(
  `${BASE_URL}/api/issues/${issueId}/checkout`,
  { agentId }
);

if (!checkoutRes.ok() && checkoutRes.status() === 409) {
  // Lock held by another agent; escalate to board-level coordination
  const boardCheckout = await board.post(
    `${BASE_URL}/api/issues/${issueId}/checkout`,
    {}
  );
  // Board checkout succeeds if the agent lock has expired or is invalid
}

This agent-first, board-fallback strategy ensures that automated agents can work independently when possible, while the board UI serves as a coordinator of last resort when exclusive locks are contested.

Activity Logging and Audit Trails

Every successful lock adoption generates an activity record via server/src/services/plugin-host-services.ts. The issue.checkout_lock_adopted event captures the issueId and checkoutRunId, creating an immutable audit trail of which execution context owned the issue at any point in time. This logging supports debugging race conditions and provides visibility into workload distribution across the agent fleet.

Automatic Cleanup of Stale Locks

To prevent deadlocks from orphaned locks when processes crash or runs terminate unexpectedly, Paperclip AI includes a recovery service in server/src/services/recovery/service.ts. This background job periodically scans for checkoutRunId and executionRunId values referencing non-existent runs:

// server/src/services/recovery/service.ts – stale lock sweeper
await db
  .update(issues)
  .set({ checkoutRunId: null, executionRunId: null })
  .where(sql`(${issues.checkoutRunId} IS NOT NULL OR ${issues.executionRunId} IS NOT NULL)`);

By clearing these columns when the associated run records are missing, the sweeper guarantees that transient failures never permanently block issue processing.

Summary

  • Database-backed exclusivity: The checkoutRunId column in server/src/services/issues.ts serves as the single source of truth for issue ownership, surviving process restarts and scaling across multiple server replicas.
  • 409 Conflict semantics: Failed checkout attempts return HTTP 409, enabling clients to implement intelligent fallback logic rather than silently duplicating work.
  • Agent/board coordination: The sameRunLock function and activity logging in server/src/services/plugin-host-services.ts support seamless handoff between automated agents and human-driven board workflows.
  • Automatic recovery: The periodic cleanup job in server/src/services/recovery/service.ts prevents indefinite deadlocks by garbage-collecting orphaned locks from crashed runs.

Frequently Asked Questions

What happens if two agents try to checkout the same issue simultaneously?

The first agent to commit its transaction writes its runId to the checkoutRunId column. The second agent’s request fails the sameRunLock validation and receives an HTTP 409 Conflict response, forcing it to retry or fall back to board-level coordination.

How does Paperclip AI handle locks when a process crashes mid-execution?

Because locks are stored in the database rather than in-memory, they persist across restarts. The recovery service in server/src/services/recovery/service.ts periodically queries for locks belonging to non-existent runs and clears them, ensuring crashed agents cannot indefinitely block issue processing.

Can a board override an agent's lock?

No—the sameRunLock function enforces strict ownership. However, the board can acquire the lock once the agent’s run record is cleaned up by the recovery service, or if the agent explicitly releases the lock by setting checkoutRunId to null upon completion.

Where is the lock state persisted in the database?

The lock state is stored in the issues table as the checkoutRunId and executionRunId columns, as defined in the schema referenced by server/src/services/issues.ts and cleaned by server/src/services/recovery/service.ts.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →