fix(issues): release the outgoing assignee's never-started run on reassignment (#21030)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 23:53:49 -06:00
1 parent a372271a40
commit ebaffddb01
2 files changed
+150 -1

No files matched your search

@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
});
});
+14 -1
View File
@@ -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<Parameters<Db["transaction"]>[0]>[0];
type IssueQueueTx = Parameters<Parameters<Db["transaction"]>[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 },