mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(heartbeat): claim issue ownership with queued runs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4196a4cd76
commit
a0676fb1d1
2 files changed
+64
-3
No files matched your search
@@ -311,6 +311,38 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it.each([true, false])("serializes assignment claims by issue without serializing unrelated work (same issue: %s)", async (sameIssue) => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent({ maxConcurrentRuns: 2 });
|
||||
const firstIssueId = randomUUID();
|
||||
const secondIssueId = sameIssue ? firstIssueId : randomUUID();
|
||||
for (const id of new Set([firstIssueId, secondIssueId])) {
|
||||
await db.insert(issues).values({ id, companyId, title: "Concurrent assignment", status: "todo", assigneeAgentId: agentId });
|
||||
}
|
||||
await seedQueuedRun({ companyId, agentId, issueId: firstIssueId, wakeReason: "issue_assigned", contextExtras: { source: "issue.create" } });
|
||||
await seedQueuedRun({ companyId, agentId, issueId: secondIssueId, wakeReason: "issue_assigned", contextExtras: { source: "issue.assigned_todo_liveness_dispatch" } });
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
mockAdapterExecute.mockImplementation(async () => {
|
||||
await gate;
|
||||
return { exitCode: 0, signal: null, timedOut: false, errorMessage: null, summary: "Assignment finished.", provider: "test", model: "test-model" };
|
||||
});
|
||||
try {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
expect(await waitForCondition(() => Promise.resolve(mockAdapterExecute.mock.calls.length > 0))).toBe(true);
|
||||
const runs = await db.select().from(heartbeatRuns);
|
||||
const running = runs.filter((run) => run.status === "running");
|
||||
expect(running).toHaveLength(sameIssue ? 1 : 2);
|
||||
expect(runs.filter((run) => run.status === "queued")).toHaveLength(sameIssue ? 1 : 0);
|
||||
for (const run of running) {
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, (run.contextSnapshot as { issueId: string }).issueId));
|
||||
expect(issue.executionRunId).toBe(run.id);
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips generic timer wakes with no actionable assigned work before adapter execution", async () => {
|
||||
const { agentId } = await seedCompanyAndAgent({
|
||||
heartbeatConfig: {
|
||||
|
||||
@@ -17639,9 +17639,38 @@ export function heartbeatService(
|
||||
agentNameKey: normalizeAgentNameKey(agent.name),
|
||||
});
|
||||
}
|
||||
return tx.update(heartbeatRuns).set(claimValues).where(and(
|
||||
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "queued"),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
return tx.transaction(async (claimTx) => {
|
||||
// Several queued assignments can enter the same admission batch
|
||||
// before executeRun registers an in-memory owner. Claim the issue
|
||||
// and run together, so only its exact owner reaches provider setup.
|
||||
const issueOwner = issueId && context.wakeReason !== "source_scoped_recovery_action"
|
||||
? await claimTx.select({
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
executionRunId: issues.executionRunId,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
}).from(issues).where(and(
|
||||
eq(issues.id, issueId), eq(issues.companyId, run.companyId),
|
||||
)).for("update").then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
if (issueOwner?.assigneeAgentId === run.agentId &&
|
||||
((issueOwner.executionRunId && issueOwner.executionRunId !== run.id) ||
|
||||
(run.scheduledRetryReason === "native_safe_replacement" &&
|
||||
issueOwner.checkoutRunId && issueOwner.checkoutRunId !== run.id))) {
|
||||
return null;
|
||||
}
|
||||
const claimedRun = await claimTx.update(heartbeatRuns).set(claimValues).where(and(
|
||||
eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "queued"),
|
||||
)).returning().then((rows) => rows[0] ?? null);
|
||||
if (claimedRun && issueId && issueOwner?.assigneeAgentId === run.agentId) {
|
||||
await claimTx.update(issues).set({
|
||||
executionRunId: claimedRun.id,
|
||||
executionAgentNameKey: normalizeAgentNameKey(agent.name),
|
||||
executionLockedAt: claimedAt,
|
||||
updatedAt: claimedAt,
|
||||
}).where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)));
|
||||
}
|
||||
return claimedRun;
|
||||
});
|
||||
});
|
||||
if (!claimed) return null;
|
||||
|
||||
|
||||
Reference in new issue
Block a user