From 9bf93a10b238f7de90cd283c827fd5f7e883a771 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 27 May 2026 23:59:52 -0700 Subject: [PATCH] Ignore actor metadata in decomposition fingerprints --- server/src/__tests__/issues-service.test.ts | 90 ++++++++++++++++++++- server/src/services/issues.ts | 33 +++++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 052ccb97ae4..ffc4c0408fe 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { sql } from "drizzle-orm"; import { @@ -3707,6 +3707,94 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { expect(childrenRows.map((row) => row.title).sort()).toEqual(children.map((child) => child.title).sort()); }); + it("resumes a partial decomposition after reassignment when only actor metadata changes", async () => { + const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue(); + const reassignedAgentId = randomUUID(); + await db.insert(agents).values({ + id: reassignedAgentId, + companyId, + name: "SecondCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const children = [ + { + title: "Keep the original child", + status: "todo" as const, + workMode: "standard" as const, + priority: "medium" as const, + createdByAgentId: assigneeAgentId, + actorAgentId: assigneeAgentId, + }, + { + title: "Create only the missing child after reassignment", + status: "todo" as const, + workMode: "standard" as const, + priority: "medium" as const, + createdByAgentId: assigneeAgentId, + actorAgentId: assigneeAgentId, + }, + ]; + + const initial = await svc.decomposeAcceptedPlan(sourceIssueId, { + acceptedPlanRevisionId, + children, + actorAgentId: assigneeAgentId, + }); + const claim = await getAcceptedPlanClaim(sourceIssueId); + const [firstChildId, secondChildId] = initial.childIssueIds; + + expect(claim).not.toBeNull(); + expect(firstChildId).toBeTruthy(); + expect(secondChildId).toBeTruthy(); + + await db.delete(issues).where(eq(issues.id, secondChildId!)); + await db + .update(issues) + .set({ assigneeAgentId: reassignedAgentId, updatedAt: new Date() }) + .where(eq(issues.id, sourceIssueId)); + await db + .update(issuePlanDecompositions) + .set({ + status: "in_flight", + childIssueIds: [firstChildId!], + completedAt: null, + ownerAgentId: assigneeAgentId, + updatedAt: new Date(), + }) + .where(eq(issuePlanDecompositions.id, claim!.id)); + + const retried = await svc.decomposeAcceptedPlan(sourceIssueId, { + acceptedPlanRevisionId, + children: children.map((child) => ({ + ...child, + createdByAgentId: reassignedAgentId, + actorAgentId: reassignedAgentId, + })), + actorAgentId: reassignedAgentId, + }); + + expect(retried.decomposition.status).toBe("completed"); + expect(retried.decomposition.ownerAgentId).toBe(reassignedAgentId); + expect(retried.childIssueIds[0]).toBe(firstChildId); + expect(retried.newlyCreatedIssues).toHaveLength(1); + expect(retried.newlyCreatedIssues[0]?.title).toBe("Create only the missing child after reassignment"); + + const childrenRows = await db + .select({ id: issues.id, title: issues.title, createdByAgentId: issues.createdByAgentId }) + .from(issues) + .where(eq(issues.parentId, sourceIssueId)) + .orderBy(asc(issues.createdAt), asc(issues.id)); + expect(childrenRows).toHaveLength(2); + expect(childrenRows.map((row) => row.id).sort()).toEqual([...retried.childIssueIds].sort()); + expect(childrenRows.find((row) => row.id !== firstChildId)?.createdByAgentId).toBe(reassignedAgentId); + }); + it("lists persisted decompositions with child issue summaries", async () => { const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue(); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 2f1f2dc44f6..ac573a4ba6d 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -438,11 +438,42 @@ function normalizeAcceptedPlanDecompositionFingerprintValue(value: unknown): unk return String(value); } +const ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS = new Set([ + "id", + "companyId", + "parentId", + "identifier", + "checkoutRunId", + "executionRunId", + "executionLockedAt", + "startedAt", + "completedAt", + "cancelledAt", + "hiddenAt", + "createdAt", + "updatedAt", + "createdByAgentId", + "createdByUserId", + "updatedByAgentId", + "updatedByUserId", + "actorAgentId", + "actorUserId", +]); + +function normalizeAcceptedPlanDecompositionFingerprintChild(child: IssueChildCreateInput) { + return Object.fromEntries( + Object.entries(child).filter(([key]) => !ACCEPTED_PLAN_DECOMPOSITION_FINGERPRINT_CHILD_METADATA_KEYS.has(key)), + ); +} + function createAcceptedPlanDecompositionRequestFingerprint(input: { acceptedPlanRevisionId: string; children: IssueChildCreateInput[]; }) { - const canonical = JSON.stringify(normalizeAcceptedPlanDecompositionFingerprintValue(input)); + const canonical = JSON.stringify(normalizeAcceptedPlanDecompositionFingerprintValue({ + acceptedPlanRevisionId: input.acceptedPlanRevisionId, + children: input.children.map(normalizeAcceptedPlanDecompositionFingerprintChild), + })); return createHash("sha256").update(canonical).digest("hex"); }