diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index 6f57084c47a..6c7d85dc709 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -133,7 +133,6 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { await new Promise((resolve) => setTimeout(resolve, 50)); } await new Promise((resolve) => setTimeout(resolve, 50)); - await db.delete(activityLog); await db.delete(companySkills); await db.delete(issueComments); await db.delete(issueDocuments); @@ -346,6 +345,86 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { expect(mockAdapterExecute).not.toHaveBeenCalled(); }); + it("cancels queued in_review runs when the current participant changes before the run starts", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const otherAgentId = randomUUID(); + await db.insert(agents).values({ + id: otherAgentId, + companyId, + name: "ReviewerAgent", + role: "qa", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } }, + permissions: {}, + }); + + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "In-review task now owned by reviewer", + status: "in_review", + priority: "medium", + assigneeAgentId: agentId, + executionState: { + status: "pending", + currentStageId: randomUUID(), + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: otherAgentId, userId: null }, + returnAssignee: { type: "agent", agentId, userId: null }, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }); + + const { runId, wakeupRequestId } = await seedQueuedRun({ + companyId, + agentId, + issueId, + wakeReason: "issue_assigned", + }); + + await heartbeat.resumeQueuedRuns(); + + await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + return run?.status === "cancelled"; + }); + + const [run, wakeup] = await Promise.all([ + db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + resultJson: heartbeatRuns.resultJson, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null), + db + .select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, wakeupRequestId)) + .then((rows) => rows[0] ?? null), + ]); + + expect(run?.status).toBe("cancelled"); + expect(run?.errorCode).toBe("issue_review_participant_changed"); + expect(run?.resultJson).toMatchObject({ stopReason: "issue_review_participant_changed" }); + expect(wakeup?.status).toBe("skipped"); + expect(wakeup?.error).toContain("in-review participant changed"); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + it("still runs comment-driven wakes on in_review issues even when the agent is no longer the current participant", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const otherAgentId = randomUUID();