Skip to content

Commit 943b851

Browse files
authored
Merge pull request paperclipai#2643 from chrisschwer/fix/stale-execution-lock-lifecycle
fix: stale execution lock lifecycle (PIP-002)
2 parents f2a2049 + 7240864 commit 943b851

2 files changed

Lines changed: 69 additions & 10 deletions

File tree

server/src/services/heartbeat.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { execFile as execFileCallback } from "node:child_process";
44
import { promisify } from "node:util";
5-
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
5+
import { and, asc, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
66
import type { Db } from "@paperclipai/db";
77
import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared";
88
import {
@@ -2214,6 +2214,29 @@ export function heartbeatService(db: Db) {
22142214
});
22152215

22162216
await setWakeupStatus(claimed.wakeupRequestId, "claimed", { claimedAt });
2217+
2218+
// Fix A (lazy locking): stamp executionRunId now that the run is actually running,
2219+
// not at queue time. Guard is idempotent — safe if called more than once.
2220+
const claimedIssueId = readNonEmptyString(parseObject(claimed.contextSnapshot).issueId);
2221+
if (claimedIssueId) {
2222+
const claimedAgent = await getAgent(claimed.agentId);
2223+
await db
2224+
.update(issues)
2225+
.set({
2226+
executionRunId: claimed.id,
2227+
executionAgentNameKey: normalizeAgentNameKey(claimedAgent?.name),
2228+
executionLockedAt: claimedAt,
2229+
updatedAt: claimedAt,
2230+
})
2231+
.where(
2232+
and(
2233+
eq(issues.id, claimedIssueId),
2234+
eq(issues.companyId, claimed.companyId),
2235+
or(isNull(issues.executionRunId), eq(issues.executionRunId, claimed.id)),
2236+
),
2237+
);
2238+
}
2239+
22172240
return claimed;
22182241
}
22192242

@@ -3940,15 +3963,9 @@ export function heartbeatService(db: Db) {
39403963
})
39413964
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
39423965

3943-
await tx
3944-
.update(issues)
3945-
.set({
3946-
executionRunId: newRun.id,
3947-
executionAgentNameKey: agentNameKey,
3948-
executionLockedAt: new Date(),
3949-
updatedAt: new Date(),
3950-
})
3951-
.where(eq(issues.id, issue.id));
3966+
// executionRunId is NOT stamped here (enqueueWakeup queues the run but
3967+
// doesn't start it). It will be stamped in claimQueuedRun() once the run
3968+
// transitions to "running" — Fix A (lazy locking).
39523969

39533970
return { kind: "queued" as const, run: newRun };
39543971
});

server/src/services/issues.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,12 +1632,20 @@ export function issueService(db: Db) {
16321632
}
16331633
if (issueData.status && issueData.status !== "in_progress") {
16341634
patch.checkoutRunId = null;
1635+
// Fix B: also clear the execution lock when leaving in_progress
1636+
patch.executionRunId = null;
1637+
patch.executionAgentNameKey = null;
1638+
patch.executionLockedAt = null;
16351639
}
16361640
if (
16371641
(issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== existing.assigneeAgentId) ||
16381642
(issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== existing.assigneeUserId)
16391643
) {
16401644
patch.checkoutRunId = null;
1645+
// Fix B: clear execution lock on reassignment, matching checkoutRunId clear
1646+
patch.executionRunId = null;
1647+
patch.executionAgentNameKey = null;
1648+
patch.executionLockedAt = null;
16411649
}
16421650

16431651
const runUpdate = async (tx: any) => {
@@ -1732,6 +1740,40 @@ export function issueService(db: Db) {
17321740
await assertAssignableAgent(issueCompany.companyId, agentId);
17331741

17341742
const now = new Date();
1743+
1744+
// Fix C: staleness detection — if executionRunId references a run that is no
1745+
// longer queued or running, clear it before applying the execution lock condition
1746+
// so a dead lock can't produce a spurious 409.
1747+
// Wrapped in a transaction with SELECT FOR UPDATE to make the read + clear atomic,
1748+
// matching the existing pattern in enqueueWakeup().
1749+
await db.transaction(async (tx) => {
1750+
await tx.execute(
1751+
sql`select id from issues where id = ${id} for update`,
1752+
);
1753+
const preCheckRow = await tx
1754+
.select({ executionRunId: issues.executionRunId })
1755+
.from(issues)
1756+
.where(eq(issues.id, id))
1757+
.then((rows) => rows[0] ?? null);
1758+
if (!preCheckRow?.executionRunId) return;
1759+
const lockRun = await tx
1760+
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
1761+
.from(heartbeatRuns)
1762+
.where(eq(heartbeatRuns.id, preCheckRow.executionRunId))
1763+
.then((rows) => rows[0] ?? null);
1764+
if (!lockRun || (lockRun.status !== "queued" && lockRun.status !== "running")) {
1765+
await tx
1766+
.update(issues)
1767+
.set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: now })
1768+
.where(
1769+
and(
1770+
eq(issues.id, id),
1771+
eq(issues.executionRunId, preCheckRow.executionRunId),
1772+
),
1773+
);
1774+
}
1775+
});
1776+
17351777
const sameRunAssigneeCondition = checkoutRunId
17361778
? and(
17371779
eq(issues.assigneeAgentId, agentId),

0 commit comments

Comments
 (0)