From d7049e0cae3c86c0d04dd3beee5b22e0e188c6d8 Mon Sep 17 00:00:00 2001
From: Vasu Yadav <107919164+ivasuy@users.noreply.github.com>
Date: Fri, 12 Jun 2026 10:33:07 +0530
Subject: [PATCH] fix(server): adopt stale checkout run ownership (#5413)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is a control plane for autonomous AI-agent companies.
> - Issue checkout ownership is part of the execution-control layer that
prevents two runs from mutating the same task at the same time.
> - The current lock model should preserve `409` conflicts for live
competing owners, but it should not strand the rightful assignee behind
a stale terminal run.
> - A same-agent follow-up run can encounter an existing `checkoutRunId`
from a failed, timed-out, succeeded, or missing heartbeat run.
> - In that case, the new run should safely adopt ownership instead of
failing with an ownership conflict.
> - This pull request makes stale checkout adoption transactional and
keeps live checkout owners protected.
> - The benefit is safer run recovery without weakening single-owner
checkout semantics.
## Linked Issues or Issue Description
- Fixes #5350
- Closes #1508
- Closes #1970
- Closes #2083
- Closes #3158
- Closes #3190
- Related stale-lock PRs reviewed during dedup search: #7536, #6658,
#5660, #5442, #6223, #7048, #6824, #6799
## What Changed
- Updated issue checkout ownership recovery so the current assignee can
adopt a stale terminal or missing checkout run.
- Added row locking around stale checkout adoption to avoid races while
replacing `checkoutRunId` / `executionRunId`.
- Preserved `409` behavior when a different live checkout owner is still
active.
- Prevented terminal actor runs from reclaiming an unowned checkout lock
after the newer eager stale-checkout clear path.
- Fixed the stale checkout test fixture so same-assignee cases do not
insert duplicate agent rows.
- Added/kept focused coverage for stale checkout adoption and live-owner
conflict behavior.
- Fixes #5350.
## Verification
- Focused tests:
```sh
pnpm exec vitest run server/src/__tests__/issues-service.test.ts server/src/__tests__/issue-stale-execution-lock-routes.test.ts
```
Result:
```text
2 passed, 84 tests passed
```
- Server typecheck:
```sh
pnpm --filter @paperclipai/server typecheck
```
Result:
```text
passed
```
- Live curl smoke confirmed same-agent stale checkout adoption returns
`200` instead of `409`.
```text
old_run_status=succeeded
checkout_http=200
patch_http=200
```
The PATCH response showed `checkoutRunId` and `executionRunId` updated
to the new run id.
### Live curl smoke result
### Server request log
## Risks
- Low to medium risk: this touches issue execution lock ownership.
- The behavioral shift is intentionally narrow: only the current
assignee can adopt stale terminal or missing checkout ownership.
- Live checkout owners remain protected with `409`.
- No database migration or API contract change.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-5.5 Codex coding agent with repository tool use, shell
execution, code review, and local verification.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
## Cross-references and status (maintainer)
- Closes #1508
- Closes #1970
- Closes #2083
- Closes #3158
- Closes #3190
- Status: rebased onto current master; focused tests and server
typecheck pass locally; all required CI is green; Greptile is 5/5;
master drift verified.
---------
Co-authored-by: Devin Foley
---
README.md | 16 +
doc/DEVELOPING.md | 16 +
.../issue-stale-execution-lock-routes.test.ts | 77 ++++
server/src/__tests__/issues-service.test.ts | 236 +++++++++++
server/src/services/issues.ts | 374 ++++++++++++------
5 files changed, 602 insertions(+), 117 deletions(-)
diff --git a/README.md b/README.md
index 6da66d03..7549c1e1 100644
--- a/README.md
+++ b/README.md
@@ -284,6 +284,22 @@ Open source. Self-hosted. No Paperclip account required.
npx paperclipai onboard --yes
```
+> **Troubleshooting: private npm registry `.npmrc`**
+>
+> If this fails with an `E404` for `paperclipai` (or similar) and you use a private npm registry (for example GitHub Packages) via a global `~/.npmrc`, `npx` may be resolving `paperclipai` against that private registry instead of the public npm registry.
+>
+> Diagnostic:
+>
+> ```bash
+> npm config get registry
+> ```
+>
+> Workaround (cross-platform; force the public npm registry for this command):
+>
+> ```bash
+> npx --registry https://registry.npmjs.org paperclipai onboard --yes
+> ```
+
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 8b79b67f..d914d700 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -132,6 +132,22 @@ For a first-time local install, you can bootstrap and run in one command:
pnpm paperclipai run
```
+> **Note: private npm registry `.npmrc` + first-run onboarding**
+>
+> The first-run experience often starts with `npx paperclipai onboard --yes` (before you have a repo checkout). If your global `~/.npmrc` sets `registry` to a private registry (for example GitHub Packages), `npx` may try to resolve `paperclipai` from that private registry and fail with `E404`.
+>
+> Diagnostic:
+>
+> ```sh
+> npm config get registry
+> ```
+>
+> Workaround (cross-platform; force the public npm registry for this command):
+>
+> ```sh
+> npx --registry https://registry.npmjs.org paperclipai onboard --yes
+> ```
+
`paperclipai run` does:
1. auto-onboard if config is missing
diff --git a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts
index a74ecb1a..1f91fa27 100644
--- a/server/src/__tests__/issue-stale-execution-lock-routes.test.ts
+++ b/server/src/__tests__/issue-stale-execution-lock-routes.test.ts
@@ -213,6 +213,83 @@ describeEmbeddedPostgres("stale issue execution lock routes", () => {
});
});
+ it("lets the current assignee recover a timed_out stale checkout owner during PATCH", async () => {
+ const { companyId, agentId, currentRunId } = await seedCompanyAgentAndRuns();
+ const timedOutRunId = randomUUID();
+ const issueId = randomUUID();
+ await db.insert(heartbeatRuns).values({
+ id: timedOutRunId,
+ companyId,
+ agentId,
+ status: "timed_out",
+ invocationSource: "manual",
+ finishedAt: new Date(),
+ });
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Stale checkout lock",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: timedOutRunId,
+ executionRunId: timedOutRunId,
+ executionAgentNameKey: "codexcoder",
+ executionLockedAt: new Date(),
+ });
+
+ const res = await request(createApp(agentActor(companyId, agentId, currentRunId)))
+ .patch(`/api/issues/${issueId}`)
+ .send({ title: "Recovered stale checkout lock" });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ const row = await db
+ .select({
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]);
+ expect(row).toEqual({
+ checkoutRunId: currentRunId,
+ executionRunId: currentRunId,
+ });
+ });
+
+ it("still returns 409 when a different live checkout owner is active", async () => {
+ const { companyId, agentId, failedRunId } = await seedCompanyAgentAndRuns();
+ const liveOwnerRunId = randomUUID();
+ const issueId = randomUUID();
+ await db.insert(heartbeatRuns).values({
+ id: liveOwnerRunId,
+ companyId,
+ agentId,
+ status: "running",
+ invocationSource: "manual",
+ startedAt: new Date(),
+ });
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Live checkout lock",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: liveOwnerRunId,
+ executionRunId: liveOwnerRunId,
+ executionAgentNameKey: "codexcoder",
+ executionLockedAt: new Date(),
+ });
+
+ const res = await request(createApp(agentActor(companyId, agentId, failedRunId)))
+ .patch(`/api/issues/${issueId}`)
+ .send({ title: "Should fail" });
+
+ expect(res.status, JSON.stringify(res.body)).toBe(409);
+ expect(res.body?.error).toBe("Issue run ownership conflict");
+ });
+
it("restricts admin force-release to board users with company access and writes an audit event", async () => {
const { companyId, agentId, failedRunId, currentRunId } = await seedCompanyAgentAndRuns();
const issueId = randomUUID();
diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts
index a7a5fae6..1c30899e 100644
--- a/server/src/__tests__/issues-service.test.ts
+++ b/server/src/__tests__/issues-service.test.ts
@@ -41,6 +41,16 @@ import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+function deferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
describe("issue list limit helpers", () => {
it("clamps untrusted issue-list limits to the server maximum", () => {
expect(clampIssueListLimit(0)).toBe(1);
@@ -4952,3 +4962,229 @@ describeEmbeddedPostgres("accepted plan decomposition", () => {
expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true);
});
});
+
+describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adoption", () => {
+ let db!: ReturnType;
+ let svc!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-checkout-owner-");
+ db = createDb(tempDb.connectionString);
+ svc = issueService(db);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(issueComments);
+ await db.delete(issueRelations);
+ await db.delete(issueInboxArchives);
+ await db.delete(activityLog);
+ await db.delete(issues);
+ await db.delete(heartbeatRuns);
+ await db.delete(executionWorkspaces);
+ await db.delete(projectWorkspaces);
+ await db.delete(projects);
+ await db.delete(goals);
+ await db.delete(agents);
+ await db.delete(instanceSettings);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ async function seedOwnershipIssue(params: {
+ checkoutStatus: "running" | "failed" | "timed_out";
+ actorRunStatus?: "running" | "failed" | "timed_out" | "succeeded";
+ assigneeMatchesActor?: boolean;
+ }) {
+ const companyId = randomUUID();
+ const assigneeAgentId = randomUUID();
+ const actorAgentId = params.assigneeMatchesActor === false ? randomUUID() : assigneeAgentId;
+ const staleRunId = randomUUID();
+ const actorRunId = randomUUID();
+ const issueId = randomUUID();
+ const actorRunStatus = params.actorRunStatus ?? "running";
+
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ const agentRows = [
+ {
+ id: assigneeAgentId,
+ companyId,
+ name: "Assignee",
+ role: "engineer" as const,
+ status: "active" as const,
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ },
+ ];
+ if (actorAgentId !== assigneeAgentId) {
+ agentRows.push({
+ id: actorAgentId,
+ companyId,
+ name: "Actor",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ });
+ }
+ await db.insert(agents).values(agentRows);
+ await db.insert(heartbeatRuns).values([
+ {
+ id: staleRunId,
+ companyId,
+ agentId: assigneeAgentId,
+ status: params.checkoutStatus,
+ invocationSource: "manual",
+ finishedAt: params.checkoutStatus === "running" ? null : new Date(),
+ },
+ {
+ id: actorRunId,
+ companyId,
+ agentId: actorAgentId,
+ status: actorRunStatus,
+ invocationSource: "manual",
+ startedAt: actorRunStatus === "running" ? new Date() : null,
+ finishedAt: actorRunStatus === "running" ? null : new Date(),
+ },
+ ]);
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Checkout owner recovery",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId,
+ checkoutRunId: staleRunId,
+ executionRunId: staleRunId,
+ executionLockedAt: new Date(),
+ executionAgentNameKey: "assignee",
+ });
+
+ return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId };
+ }
+
+ it("lets the current assignee adopt a stale terminal checkout owner", async () => {
+ const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
+
+ const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
+
+ expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
+ expect(ownership.executionRunId).toBe(seeded.actorRunId);
+ expect(ownership.adoptedFromRunId).toBeNull();
+ });
+
+ it("treats timed_out checkout owners as stale and recoverable", async () => {
+ const seeded = await seedOwnershipIssue({ checkoutStatus: "timed_out" });
+
+ const ownership = await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
+
+ expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
+ expect(ownership.adoptedFromRunId).toBeNull();
+ });
+
+ it("does not allow non-assignees to adopt stale checkout ownership", async () => {
+ const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", assigneeMatchesActor: false });
+
+ await expect(
+ svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
+ ).rejects.toMatchObject({ status: 409 });
+ });
+
+ it("keeps live checkout owners protected with a 409 conflict", async () => {
+ const seeded = await seedOwnershipIssue({ checkoutStatus: "running" });
+
+ await expect(
+ svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
+ ).rejects.toMatchObject({ status: 409 });
+ });
+
+ it("does not let terminal actor runs adopt stale checkout ownership", async () => {
+ const seeded = await seedOwnershipIssue({ checkoutStatus: "failed", actorRunStatus: "succeeded" });
+
+ await expect(
+ svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId),
+ ).rejects.toMatchObject({ status: 409 });
+
+ const row = await db
+ .select({
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .from(issues)
+ .where(eq(issues.id, seeded.issueId))
+ .then((rows) => rows[0]);
+ expect(row).toEqual({
+ checkoutRunId: null,
+ executionRunId: null,
+ });
+ });
+
+ it("adopts unowned checkout after a concurrent stale-checkout clear wins the lock race", async () => {
+ const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
+ await db
+ .update(issues)
+ .set({
+ executionRunId: seeded.actorRunId,
+ executionLockedAt: new Date(),
+ })
+ .where(eq(issues.id, seeded.issueId));
+
+ const rowLocked = deferred();
+ const clearCanCommit = deferred();
+
+ const concurrentClear = db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select ${issues.id} from ${issues} where ${issues.id} = ${seeded.issueId} for update`,
+ );
+ rowLocked.resolve();
+ await clearCanCommit.promise;
+ await tx
+ .update(issues)
+ .set({
+ checkoutRunId: null,
+ executionRunId: seeded.actorRunId,
+ executionLockedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(issues.id, seeded.issueId));
+ });
+
+ await rowLocked.promise;
+
+ const ownershipPromise = svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ clearCanCommit.resolve();
+ await concurrentClear;
+
+ const ownership = await ownershipPromise;
+ expect(ownership.checkoutRunId).toBe(seeded.actorRunId);
+ expect(ownership.executionRunId).toBe(seeded.actorRunId);
+ expect(ownership.adoptedFromRunId).toBeNull();
+
+ const row = await db
+ .select({
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .from(issues)
+ .where(eq(issues.id, seeded.issueId))
+ .then((rows) => rows[0]);
+ expect(row).toEqual({
+ checkoutRunId: seeded.actorRunId,
+ executionRunId: seeded.actorRunId,
+ });
+ });
+
+});
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index 93e72ddb..4a1ed03d 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -3661,36 +3661,99 @@ export function issueService(db: Db) {
actorRunId: string;
expectedCheckoutRunId: string;
}) {
- const stale = await isTerminalOrMissingHeartbeatRun(input.expectedCheckoutRunId);
- if (!stale) return null;
+ return db.transaction(async (tx) => {
+ const lockedIssue = await tx
+ .select({
+ id: issues.id,
+ status: issues.status,
+ assigneeAgentId: issues.assigneeAgentId,
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .from(issues)
+ .where(eq(issues.id, input.issueId))
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+ if (!lockedIssue) {
+ return { adopted: null, latest: null };
+ }
- const now = new Date();
- const adopted = await db
- .update(issues)
- .set({
- checkoutRunId: input.actorRunId,
- executionRunId: input.actorRunId,
- executionLockedAt: now,
- updatedAt: now,
- })
- .where(
- and(
- eq(issues.id, input.issueId),
- eq(issues.status, "in_progress"),
- eq(issues.assigneeAgentId, input.actorAgentId),
- eq(issues.checkoutRunId, input.expectedCheckoutRunId),
+ if (
+ lockedIssue.status !== "in_progress" ||
+ lockedIssue.assigneeAgentId !== input.actorAgentId ||
+ lockedIssue.checkoutRunId !== input.expectedCheckoutRunId
+ ) {
+ return { adopted: null, latest: lockedIssue };
+ }
+
+ await Promise.all([
+ tx.execute(
+ sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.expectedCheckoutRunId} for update`,
),
- )
- .returning({
- id: issues.id,
- status: issues.status,
- assigneeAgentId: issues.assigneeAgentId,
- checkoutRunId: issues.checkoutRunId,
- executionRunId: issues.executionRunId,
- })
- .then((rows) => rows[0] ?? null);
+ tx.execute(
+ sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
+ ),
+ ]);
+ const [existingRun, actorRun] = await Promise.all([
+ tx
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, input.expectedCheckoutRunId))
+ .then((rows) => rows[0] ?? null),
+ tx
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, input.actorRunId))
+ .then((rows) => rows[0] ?? null),
+ ]);
+ const stale = !existingRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingRun.status);
+ const actorLive = actorRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status);
+ if (!stale || !actorLive) {
+ return { adopted: null, latest: lockedIssue };
+ }
- return adopted;
+ const now = new Date();
+ const adopted = await tx
+ .update(issues)
+ .set({
+ checkoutRunId: input.actorRunId,
+ executionRunId: input.actorRunId,
+ executionLockedAt: now,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(issues.id, input.issueId),
+ eq(issues.status, "in_progress"),
+ eq(issues.assigneeAgentId, input.actorAgentId),
+ eq(issues.checkoutRunId, input.expectedCheckoutRunId),
+ ),
+ )
+ .returning({
+ id: issues.id,
+ status: issues.status,
+ assigneeAgentId: issues.assigneeAgentId,
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .then((rows) => rows[0] ?? null);
+ if (adopted) {
+ return { adopted, latest: adopted };
+ }
+
+ const latest = await tx
+ .select({
+ id: issues.id,
+ status: issues.status,
+ assigneeAgentId: issues.assigneeAgentId,
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .from(issues)
+ .where(eq(issues.id, input.issueId))
+ .then((rows) => rows[0] ?? null);
+ return { adopted: null, latest };
+ });
}
async function adoptUnownedCheckoutRun(input: {
@@ -3698,34 +3761,46 @@ export function issueService(db: Db) {
actorAgentId: string;
actorRunId: string;
}) {
- const now = new Date();
- const adopted = await db
- .update(issues)
- .set({
- checkoutRunId: input.actorRunId,
- executionRunId: input.actorRunId,
- executionLockedAt: now,
- updatedAt: now,
- })
- .where(
- and(
- eq(issues.id, input.issueId),
- eq(issues.status, "in_progress"),
- eq(issues.assigneeAgentId, input.actorAgentId),
- isNull(issues.checkoutRunId),
- or(isNull(issues.executionRunId), eq(issues.executionRunId, input.actorRunId)),
- ),
- )
- .returning({
- id: issues.id,
- status: issues.status,
- assigneeAgentId: issues.assigneeAgentId,
- checkoutRunId: issues.checkoutRunId,
- executionRunId: issues.executionRunId,
- })
- .then((rows) => rows[0] ?? null);
+ return db.transaction(async (tx) => {
+ await tx.execute(
+ sql`select ${heartbeatRuns.id} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.actorRunId} for update`,
+ );
+ const actorRun = await tx
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, input.actorRunId))
+ .then((rows) => rows[0] ?? null);
+ if (!actorRun || TERMINAL_HEARTBEAT_RUN_STATUSES.has(actorRun.status)) return null;
- return adopted;
+ const now = new Date();
+ const adopted = await tx
+ .update(issues)
+ .set({
+ checkoutRunId: input.actorRunId,
+ executionRunId: input.actorRunId,
+ executionLockedAt: now,
+ updatedAt: now,
+ })
+ .where(
+ and(
+ eq(issues.id, input.issueId),
+ eq(issues.status, "in_progress"),
+ eq(issues.assigneeAgentId, input.actorAgentId),
+ isNull(issues.checkoutRunId),
+ or(isNull(issues.executionRunId), eq(issues.executionRunId, input.actorRunId)),
+ ),
+ )
+ .returning({
+ id: issues.id,
+ status: issues.status,
+ assigneeAgentId: issues.assigneeAgentId,
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .then((rows) => rows[0] ?? null);
+
+ return adopted;
+ });
}
async function clearExecutionRunIfTerminal(issueId: string): Promise {
@@ -5480,13 +5555,13 @@ export function issueService(db: Db) {
current.checkoutRunId &&
current.checkoutRunId !== checkoutRunId
) {
- const adopted = await adoptStaleCheckoutRun({
+ const staleAdoption = await adoptStaleCheckoutRun({
issueId: id,
actorAgentId: agentId,
actorRunId: checkoutRunId,
expectedCheckoutRunId: current.checkoutRunId,
});
- if (adopted) {
+ if (staleAdoption.adopted) {
const row = await db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0] ?? null);
if (!row) throw notFound("Issue not found");
const [enriched] = await withIssueLabels(db, [row]);
@@ -5562,77 +5637,142 @@ export function issueService(db: Db) {
assertCheckoutOwner: async (id: string, actorAgentId: string, actorRunId: string | null) => {
await clearExecutionRunIfTerminal(id);
await clearCheckoutRunIfTerminal(id);
- const current = await db
- .select({
- id: issues.id,
- status: issues.status,
- assigneeAgentId: issues.assigneeAgentId,
- checkoutRunId: issues.checkoutRunId,
- executionRunId: issues.executionRunId,
- })
- .from(issues)
- .where(eq(issues.id, id))
- .then((rows) => rows[0] ?? null);
+ const loadCurrent = () =>
+ db
+ .select({
+ id: issues.id,
+ status: issues.status,
+ assigneeAgentId: issues.assigneeAgentId,
+ checkoutRunId: issues.checkoutRunId,
+ executionRunId: issues.executionRunId,
+ })
+ .from(issues)
+ .where(eq(issues.id, id))
+ .then((rows) => rows[0] ?? null);
+ const current = await loadCurrent();
if (!current) throw notFound("Issue not found");
- if (
- current.status === "in_progress" &&
- current.assigneeAgentId === actorAgentId &&
- sameRunLock(current.checkoutRunId, actorRunId)
- ) {
- return { ...current, adoptedFromRunId: null as string | null };
- }
+ const resolveSameRunOwnership = (candidate: {
+ id: string;
+ status: string;
+ assigneeAgentId: string | null;
+ checkoutRunId: string | null;
+ executionRunId: string | null;
+ }) => {
+ if (
+ candidate.status === "in_progress" &&
+ candidate.assigneeAgentId === actorAgentId &&
+ sameRunLock(candidate.checkoutRunId, actorRunId)
+ ) {
+ return { ...candidate, adoptedFromRunId: null as string | null };
+ }
+ return null;
+ };
- if (
- actorRunId &&
- current.status === "in_progress" &&
- current.assigneeAgentId === actorAgentId &&
- current.checkoutRunId == null &&
- (current.executionRunId == null || current.executionRunId === actorRunId)
- ) {
- const adopted = await adoptUnownedCheckoutRun({
- issueId: id,
+ const canAdoptUnownedCheckout = (candidate: {
+ status: string;
+ assigneeAgentId: string | null;
+ checkoutRunId: string | null;
+ executionRunId: string | null;
+ }) => (
+ actorRunId
+ && candidate.status === "in_progress"
+ && candidate.assigneeAgentId === actorAgentId
+ && candidate.checkoutRunId == null
+ && (candidate.executionRunId == null || candidate.executionRunId === actorRunId)
+ );
+
+ const resolveOwnership = async (
+ candidate: {
+ id: string;
+ status: string;
+ assigneeAgentId: string | null;
+ checkoutRunId: string | null;
+ executionRunId: string | null;
+ },
+ ) => {
+ const sameRunOwnership = resolveSameRunOwnership(candidate);
+ if (sameRunOwnership) return { ownership: sameRunOwnership, latest: null };
+
+ if (canAdoptUnownedCheckout(candidate)) {
+ const adopted = await adoptUnownedCheckoutRun({
+ issueId: id,
+ actorAgentId,
+ actorRunId: actorRunId!,
+ });
+
+ if (adopted) {
+ return {
+ ownership: {
+ ...adopted,
+ adoptedFromRunId: null as string | null,
+ },
+ latest: null,
+ };
+ }
+ }
+
+ if (
+ actorRunId &&
+ candidate.status === "in_progress" &&
+ candidate.assigneeAgentId === actorAgentId &&
+ candidate.checkoutRunId &&
+ candidate.checkoutRunId !== actorRunId
+ ) {
+ const previousCheckoutRunId = candidate.checkoutRunId;
+ const staleAdoption = await adoptStaleCheckoutRun({
+ issueId: id,
+ actorAgentId,
+ actorRunId,
+ expectedCheckoutRunId: previousCheckoutRunId,
+ });
+
+ if (staleAdoption.adopted) {
+ return {
+ ownership: {
+ ...staleAdoption.adopted,
+ adoptedFromRunId: previousCheckoutRunId,
+ },
+ latest: null,
+ };
+ }
+
+ if (staleAdoption.latest) {
+ const latestOwnership = resolveSameRunOwnership(staleAdoption.latest);
+ if (latestOwnership) return { ownership: latestOwnership, latest: staleAdoption.latest };
+ return { ownership: null, latest: staleAdoption.latest };
+ }
+ }
+
+ return { ownership: null, latest: null };
+ };
+
+ const resolved = await resolveOwnership(current);
+ if (resolved.ownership) return resolved.ownership;
+
+ const latest = resolved.latest ?? await loadCurrent();
+ if (!latest) throw notFound("Issue not found");
+ const resolvedLatest = await resolveOwnership(latest);
+ if (resolvedLatest.ownership) return resolvedLatest.ownership;
+ if (resolvedLatest.latest) {
+ throw conflict("Issue run ownership conflict", {
+ issueId: resolvedLatest.latest.id,
+ status: resolvedLatest.latest.status,
+ assigneeAgentId: resolvedLatest.latest.assigneeAgentId,
+ checkoutRunId: resolvedLatest.latest.checkoutRunId,
+ executionRunId: resolvedLatest.latest.executionRunId,
actorAgentId,
actorRunId,
});
-
- if (adopted) {
- return {
- ...adopted,
- adoptedFromRunId: null as string | null,
- };
- }
- }
-
- if (
- actorRunId &&
- current.status === "in_progress" &&
- current.assigneeAgentId === actorAgentId &&
- current.checkoutRunId &&
- current.checkoutRunId !== actorRunId
- ) {
- const adopted = await adoptStaleCheckoutRun({
- issueId: id,
- actorAgentId,
- actorRunId,
- expectedCheckoutRunId: current.checkoutRunId,
- });
-
- if (adopted) {
- return {
- ...adopted,
- adoptedFromRunId: current.checkoutRunId,
- };
- }
}
throw conflict("Issue run ownership conflict", {
- issueId: current.id,
- status: current.status,
- assigneeAgentId: current.assigneeAgentId,
- checkoutRunId: current.checkoutRunId,
- executionRunId: current.executionRunId,
+ issueId: latest.id,
+ status: latest.status,
+ assigneeAgentId: latest.assigneeAgentId,
+ checkoutRunId: latest.checkoutRunId,
+ executionRunId: latest.executionRunId,
actorAgentId,
actorRunId,
});