From ebaffddb010903d6aa04300aca605902baf58d20 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:53:49 -0600 Subject: [PATCH] fix(issues): release the outgoing assignee's never-started run on reassignment (#21030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A lead agent creates tasks and assigns them to other agents > - A task created without an assignee goes to its creator, with a queued run that has not started yet > - When the lead then reassigns the task, the route stopped only a running run. The queued run kept the task's execution lock > - The new assignee's wake waited behind that lock, so nothing ran until the queue reached and withheld the old run > - This pull request releases the outgoing assignee's never-started run on reassignment and wakes the new assignee at once > - The benefit is that reassignment starts the new owner's work immediately, without a stale lock or a duplicate task ## Linked Issues or Issue Description **What happened?** In the paid chat eval `tasks.attachment-brief`, the CEO created a task with no assignee, so it went to the CEO with a queued run. The CEO then reassigned it to Morgan. The CEO's queued run kept the execution lock. Morgan's assignment wake was deferred behind it. After 86 seconds the queue cancelled the stale run as `issue_assignee_changed`. Before that, the CEO got a 403 when it tried to cancel the run, and it created a second copy of the task. **Steps to reproduce** 1. Assign a task to an agent that is busy, so the task's assignment run stays queued. 2. Reassign the task to another agent. 3. The queued run keeps the execution lock, and the new assignee's wake is deferred. **Expected behavior** Reassignment cancels the outgoing assignee's never-started run for that task and wakes the new assignee at once. ## What Changed - `server/src/routes/issues.ts`: on reassignment, when no running run holds the task, also stop the outgoing assignee's queued run that holds the task's execution lock. The stop uses the existing `issue_reassigned` code and the existing stop settlement. - Reassignment stops pass `suppressImmediateRecovery: true`. Without it, releasing the lock queued an `issue_assignment_recovery` run for the outgoing assignee, which took the lock back before the reassignment was saved. The new assignee's wake is the successor. - `server/src/__tests__/issue-reassignment-queued-run.test.ts`: a database-backed route test. It is red on master (the queued run stays queued and holds the lock, and the new assignee's wake is `deferred_issue_execution`). It is green here (the run is cancelled as `issue_reassigned`, and the new assignee has an admitted run for the task). A control case shows that a queued lock holder that is not the outgoing assignee's run is not touched. ## Verification - `cd server && npx vitest run src/__tests__/issue-reassignment-queued-run.test.ts` (2 passed; red on master) - `cd server && npx vitest run src/__tests__/issue-agent-mutation-ownership-routes.test.ts src/__tests__/issue-queued-comments-routes.test.ts src/__tests__/human-directed-work.test.ts` (378 passed) - `cd server && npx tsc --noEmit` passes. - Paid E2E run on a combined branch: https://github.com/paperclipai/paperclip/actions/runs/43161593494 (results in a comment below). ## Risks - Behavior change: a reassignment now cancels the outgoing assignee's queued run that holds the task. Before, that run was cancelled later at claim time with `issue_assignee_changed`. The eval and the run log already recognize both codes. - Reassignment stops no longer queue an immediate recovery run for the outgoing assignee. If the reassignment write then fails, the periodic recovery sweep still recovers the task. - No schema or saved-state change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../issue-reassignment-queued-run.test.ts | 136 ++++++++++++++++++ server/src/routes/issues.ts | 15 +- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 server/src/__tests__/issue-reassignment-queued-run.test.ts diff --git a/server/src/__tests__/issue-reassignment-queued-run.test.ts b/server/src/__tests__/issue-reassignment-queued-run.test.ts new file mode 100644 index 00000000000..151e9fc5799 --- /dev/null +++ b/server/src/__tests__/issue-reassignment-queued-run.test.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agentWakeupRequests, + agents, + companies, + companyMemberships, + createDb, + heartbeatRuns, + issues, +} from "@paperclipai/db"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping reassignment queued-run tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("reassigning a task whose previous assignee's run has not started", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reassign-queued-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.execute(sql`TRUNCATE TABLE companies CASCADE`); + }); + + afterAll(async () => { + await db?.$client.end(); + await tempDb?.cleanup(); + }); + + function app(companyId: string) { + const testApp = express(); + testApp.use(express.json()); + testApp.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "session", + userId: "board-owner", + companyIds: [companyId], + memberships: [{ companyId, status: "active", membershipRole: "owner" }], + isInstanceAdmin: false, + }; + next(); + }); + testApp.use("/api", issueRoutes(db, {} as any, {})); + testApp.use(errorHandler); + return testApp; + } + + async function seed() { + const companyId = randomUUID(); + const ceoId = randomUUID(); + const morganId = randomUUID(); + const issueId = randomUUID(); + const queuedRunId = randomUUID(); + await db.insert(companies).values({ + id: companyId, name: "Reassign Co", issuePrefix: "REA", requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ceoId, morganId].map((id, index) => ({ + id, companyId, name: index === 0 ? "CEO" : "Morgan", role: index === 0 ? "ceo" : "engineer", + status: "idle", adapterType: "paperclip_runner", adapterConfig: {}, + runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } }, permissions: {}, + }))); + await db.insert(companyMemberships).values({ + companyId, principalType: "user", principalId: "board-owner", status: "active", membershipRole: "owner", + }); + await db.insert(heartbeatRuns).values([ceoId, morganId].map(agentId => ({ + companyId, agentId, status: "running", startedAt: new Date(), contextSnapshot: { issueId: randomUUID() }, + }))); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, companyId, agentId: ceoId, invocationSource: "assignment", triggerDetail: "system", + status: "queued", contextSnapshot: { issueId, wakeReason: "issue_assigned" }, + }); + await db.insert(issues).values({ + id: issueId, companyId, identifier: "REA-1", title: "Write the announcement", status: "todo", + priority: "medium", assigneeAgentId: ceoId, executionRunId: queuedRunId, + }); + return { companyId, ceoId, morganId, issueId, queuedRunId }; + } + + it("cancels the old assignee's never-started run and wakes the new assignee at once", async () => { + const f = await seed(); + await request(app(f.companyId)).patch(`/api/issues/${f.issueId}`) + .send({ assigneeAgentId: f.morganId }).expect(200); + + const [stale] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.queuedRunId)); + expect(stale).toMatchObject({ status: "cancelled", errorCode: "issue_reassigned" }); + + await vi.waitFor(async () => { + const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId)); + expect(issue.assigneeAgentId).toBe(f.morganId); + expect(issue.executionRunId).not.toBe(f.queuedRunId); + const morganWakes = await db.select().from(agentWakeupRequests).where(and( + eq(agentWakeupRequests.companyId, f.companyId), eq(agentWakeupRequests.agentId, f.morganId))); + expect(morganWakes).toHaveLength(1); + expect(morganWakes[0]!.status).not.toBe("deferred_issue_execution"); + expect(morganWakes[0]!.runId).toBeTruthy(); + const [morganRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, morganWakes[0]!.runId!)); + expect(morganRun).toMatchObject({ agentId: f.morganId }); + expect(morganRun!.status).not.toBe("cancelled"); + expect(morganRun!.contextSnapshot).toMatchObject({ issueId: f.issueId }); + }); + }); + + it("leaves a queued lock holder that is not the outgoing assignee's run alone", async () => { + const f = await seed(); + const otherRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: otherRunId, companyId: f.companyId, agentId: f.morganId, status: "queued", + contextSnapshot: { issueId: f.issueId }, + }); + await db.update(issues).set({ executionRunId: otherRunId }).where(eq(issues.id, f.issueId)); + await request(app(f.companyId)).patch(`/api/issues/${f.issueId}`) + .send({ assigneeAgentId: f.morganId }).expect(200); + const [other] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, otherRunId)); + expect(other.status).toBe("queued"); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 23a71250345..ce82b68095b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7665,6 +7665,17 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + async function resolveQueuedOutgoingAssigneeRun(issue: { + companyId: string; + assigneeAgentId: string | null; + executionRunId?: string | null; + }) { + if (!issue.executionRunId || !issue.assigneeAgentId) return null; + const run = await heartbeat.getRun(issue.executionRunId); + return run && run.status === "queued" && run.companyId === issue.companyId && + run.agentId === issue.assigneeAgentId ? run : null; + } + type IssueQueueDb = Db | Parameters[0]>[0]; type IssueQueueTx = Parameters[0]>[0]; type IssueQueueWake = typeof agentWakeupRequests.$inferSelect; @@ -15670,7 +15681,8 @@ export function issueRoutes( issueId: existing.id, agentId: existing.assigneeAgentId, }); - const runToStopForReassignment = await resolveActiveIssueRun(existing); + const runToStopForReassignment = await resolveActiveIssueRun(existing) ?? + await resolveQueuedOutgoingAssigneeRun(existing); // The run handing its own task to a person finishes its turn instead of // cancelling itself. Native and legacy alike: the reassignment clears // the execution lock, the native tool binding and finalizer stop acting @@ -15708,6 +15720,7 @@ export function issueRoutes( "Cancelled before issue reassignment", { errorCode: "issue_reassigned", + suppressImmediateRecovery: true, resultJson: { reassignmentStopConfirmed: true, issueMutationStopId }, eventMessage: "run cancelled before issue reassignment", eventPayload: { issueId: existing.id },