What Is Atomic Task Checkout in Paperclip and How Execution Locks Prevent Double-Work
Atomic task checkout in Paperclip is a database-level locking mechanism that guarantees only one agent can claim an issue at a time, while execution locks prevent any second agent from starting work on an already-active task.
Paperclip's control-plane implements a single-assignee model for every issue. Before an agent can work on a task, it must obtain an exclusive checkout through an atomic database transaction. This design eliminates race conditions where multiple agents might accidentally process the same issue—commonly known as "double-work."
How Atomic Checkout Works
The checkout flow lives in server/src/services/issues.ts. It combines row-level locking with strict ownership validation to ensure mutual exclusion.
The Database Lock Acquisition
When an agent requests checkout, the server issues a SELECT … FOR UPDATE query to lock the row:
await sql`SELECT ${heartbeatRuns.id} FROM ${heartbeatRuns}
WHERE ${heartbeatRuns.id} = ${issue.checkoutRunId}
FOR UPDATE`.execute();
This query at lines 5312–5317 in server/src/services/issues.ts guarantees that no other transaction can modify the issue until the current one completes. The lock applies to both checkoutRunId and executionRunId columns.
Same-Run Lock Validation
The helper function sameRunLock enforces ownership rules:
const sameRunLock = (checkoutRunId, actorRunId) =>
actorRunId ? checkoutRunId === actorRunId : checkoutRunId == null;
Located at lines 773–775, this function allows an agent to reuse its own lock but rejects any attempt by a different agent to steal the checkout.
Atomic Update on Success
If validation passes, the checkout columns update in the same transaction:
await sql`UPDATE ${issues}
SET checkoutRunId = ${requestRunId}
WHERE id = ${issueId}`.execute();
This pattern at lines 8096–8103 ensures the assignment happens atomically—no gap exists where another transaction could sneak in.
What Happens on Checkout Conflict
When another agent already holds the lock, the server immediately returns a 409 Conflict:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": "checkout_conflict",
"ownerAgentId": "agent-1234"
}
The client receives the competing agent's ID and can decide whether to retry, select a different issue, or wait.
How Execution Locks Prevent Double-Work
After checkout, agents transition issues to in_progress by acquiring an execution lock. This second lock prevents the scenario where one agent checks out a task, stalls, and a second agent starts working anyway.
Execution Lock Acquisition
await sql`UPDATE ${issues}
SET executionRunId = ${runId},
executionAgentNameKey = ${agentNameKey}
WHERE id = ${issueId}
AND (executionRunId IS NULL OR executionRunId = ${runId})`.execute();
The WHERE clause enforces idempotency—only unclaimed issues or the same run can proceed. This blocks any interleaved execution attempt cold.
Two-Layer Protection
| Layer | Purpose | Prevents |
|---|---|---|
| Checkout lock | Claims issue for preparation work | Duplicate assignment, agent collisions |
| Execution lock | Grants exclusive work authorization | Double-execution, split-brain processing |
Together these implement pessimistic concurrency control: the system assumes conflicts will occur and actively prevents them through database-level enforcement rather than optimistic retry loops.
Stale Lock Recovery
Dead agents could theoretically block progress forever. Paperclip solves this through a sweeper service in server/src/services/recovery/service.ts:
// Simplified sweeper logic from lines 5640–5665
async function clearStaleLocks() {
const deadRuns = await findTerminatedRuns();
await sql`UPDATE ${issues}
SET checkoutRunId = NULL,
executionRunId = NULL
WHERE checkoutRunId IN (${deadRuns})
OR executionRunId IN (${deadRuns})`.execute();
}
This background process runs continuously, detecting when heartbeat runs terminate or go missing, then releasing their associated locks. The sweeper guarantees that atomic task checkout cannot indefinitely stall the work queue.
Client-Side Checkout Integration
Agents initiate checkout through the API endpoint defined in ui/src/api/issues.ts (lines 219–220):
await api.post<Issue>(`/issues/${issueId}/checkout`, {
agentId,
expectedStatuses: ["open", "assigned"],
checkoutRunId: run.id,
});
The expectedStatuses array provides additional validation—checkout only succeeds when the issue is in an appropriate state, adding a third layer of protection against stale data.
Design Documentation
Paperclip's specification documents the atomic checkout intent explicitly:
doc/SPEC.md(line 391): Defines the single-assignee requirementdoc/SPEC-implementation.md(line 39): Maps the abstract design to concretecheckoutRunIdandexecutionRunIddatabase columns
This traceability from specification through implementation ensures the execution lock behavior remains consistent across code changes.
Summary
- Atomic task checkout uses
SELECT … FOR UPDATEto serialize all claim attempts on a single database row - The
sameRunLockhelper enforces that only the owning agent can reuse or release a lock - Execution locks create a second barrier that prevents work from starting on already-active issues
- 409 Conflict responses surface lock contention immediately so agents can redirect effort
- The recovery sweeper in
server/src/services/recovery/service.tsautomatically clears locks from dead runs - Combined, these mechanisms implement pessimistic concurrency control that prevents double-work without requiring distributed coordination protocols
Frequently Asked Questions
What happens if two agents request checkout on the same issue simultaneously?
The database's FOR UPDATE lock serializes the transactions. Whichever request acquires the lock first performs the validation and update; the second waits for the lock, then fails the sameRunLock check and receives a 409 Conflict with the winning agent's ID. No double-work occurs because the atomic transaction boundary prevents any interleaved state.
Can an agent lose its checkout to another agent?
No. The sameRunLock function at server/src/services/issues.ts:773-775 explicitly requires checkoutRunId === actorRunId for reuse. A different run ID always triggers rejection. The only way a checkout transfers is if the original agent's run terminates and the recovery sweeper clears the stale lock.
How does Paperclip handle agent crashes during execution?
The recovery sweeper monitors heartbeat runs continuously. When a run terminates or fails health checks, the sweeper (at server/src/services/recovery/service.ts:5640-5665) atomically NULLs out checkoutRunId and executionRunId for all associated issues. This releases the locks without manual intervention and makes issues available for new checkout attempts.
What's the difference between checkout and execution locks?
Checkout grants permission to prepare for work—read code, fetch dependencies, initialize tools. Execution grants permission to actually modify state. This separation prevents an agent from stalling after checkout (with work half-done) while another agent starts fresh. Both locks use the same atomic database patterns but protect distinct lifecycle phases.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →