mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
Add accepted-plan decomposition exact-once guards and UI state (#6831)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so planning approvals and child-issue fan-out are part of the core control-plane loop. > - Accepted plans are supposed to be a safe bridge from planning into execution, especially when agents wake from review decisions and reuse isolated workspaces. > - The duplicate-subtask incident showed that an accepted plan revision could be interpreted more than once across overlapping runs, which broke the single-source-of-truth model for issue decomposition. > - Fixing that required tightening the backend contract first: accepted-plan decomposition needs an exact-once fingerprint, durable claim state, and retry-safe child creation. > - Once that backend behavior existed, the board still needed visibility into what happened, so the issue detail view needed a dedicated decomposition section instead of forcing operators to reconstruct child creation from raw activity. > - This pull request adds the exact-once decomposition primitive, hardens wake routing and regressions around the incident, and surfaces decomposition state in the UI so future incidents are both prevented and easier to inspect. ## What Changed - Added accepted-plan decomposition semantics to `doc/execution-semantics.md`, including the exact-once fingerprint, durable claim/result expectations, and retry/resume behavior. - Added persistent accepted-plan decomposition claims in the backend, including schema, shared types/validators, service logic, and issue routes for creating and listing decomposition state. - Hardened heartbeat routing so an accepted-plan continuation stays scoped to the relevant planning issue instead of opportunistically re-decomposing another accepted issue on the same assignee. - Added regression coverage for the original failure modes: concurrent same-parent retries, cross-issue accepted-plan isolation, and partial child recreation under the same fingerprint. - Added the `Plan decomposition` issue-detail section plus supporting API/query-key/activity formatting updates so operators can see revision status, owner, child counts, and the linked child issues directly in the UI. - Included the small follow-up UI fix so the decomposition section still renders when the issue work mode is no longer `planning`. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "lists persisted decompositions with child issue summaries"` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "accepted plan decomposition" server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - Manual UI path: create a planning issue without an isolated execution workspace, add a `plan` document, accept the `request_confirmation`, let Paperclip create child issues, then reopen the parent issue detail page and confirm the `Plan decomposition` section shows the accepted revision, status, idempotent-claim badge, and child links. - Separate follow-up bug noted during manual UI validation: accepting a plan on an issue whose run never records `workspace_finalize` is tracked in `PAPA-445` and is not part of this PR’s fix scope. ## Risks - This adds a new migration and a large Drizzle snapshot update; reviewers should confirm the schema shape and generated metadata match the intended decomposition table. - The exact-once claim changes sit on the accepted-plan fan-out path, so regressions there could block legitimate child creation or mis-handle retries if the claim state machine is wrong. - The new UI only appears when decomposition records exist; reviewers should use the manual verification path above rather than expecting existing issues on a stale local instance to show the section automatically. - `PAPA-445` remains an open follow-up for the `workspace_finalize` accept gate when a planning handoff never records finalize; that bug can interfere with reproducing the UI flow on isolated workspaces but does not change the correctness of the exact-once decomposition feature itself. > Checked `ROADMAP.md`: this PR is a bug fix / control-plane hardening change for accepted-plan decomposition, not a new uncoordinated roadmap feature. ## Model Used - OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent; exact backend model ID/context window not exposed in the run context), with repository tool use, shell execution, and code-editing capabilities. <img width="806" height="1069" alt="Screenshot 2026-05-27 at 11 05 48 PM" src="https://github.com/user-attachments/assets/5b00b670-96cd-4470-b0a3-581743bcae28" /> ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] 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] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
9eac727cf1
commit
d9f91576a0
32 changed files with 22308 additions and 16 deletions
|
|
@ -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 {
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environments,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
|
|
@ -14,7 +16,10 @@ import {
|
|||
instanceSettings,
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issueDocuments,
|
||||
issuePlanDecompositions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
|
|
@ -3236,3 +3241,702 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => {
|
|||
expect(row).toEqual({ executionRunId: null, executionLockedAt: null });
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("accepted plan decomposition", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let svc!: ReturnType<typeof issueService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-decomposition-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
svc = issueService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueInboxArchives);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedAcceptedPlanContext() {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Accepted plan decomposition",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
return { companyId, goalId, assigneeAgentId };
|
||||
}
|
||||
|
||||
async function seedAcceptedPlanIssue(args?: {
|
||||
companyId?: string;
|
||||
goalId?: string;
|
||||
assigneeAgentId?: string;
|
||||
sourceIssueId?: string;
|
||||
issueTitle?: string;
|
||||
workMode?: "planning" | "standard";
|
||||
}) {
|
||||
const companyId = args?.companyId ?? randomUUID();
|
||||
const goalId = args?.goalId ?? randomUUID();
|
||||
const assigneeAgentId = args?.assigneeAgentId ?? randomUUID();
|
||||
const sourceIssueId = args?.sourceIssueId ?? randomUUID();
|
||||
const planDocumentId = randomUUID();
|
||||
const acceptedPlanRevisionId = randomUUID();
|
||||
const acceptedInteractionId = randomUUID();
|
||||
|
||||
if (!args?.companyId || !args?.goalId || !args?.assigneeAgentId) {
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Accepted plan decomposition",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: sourceIssueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: args?.issueTitle ?? "Planning issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
workMode: args?.workMode ?? "planning",
|
||||
assigneeAgentId: assigneeAgentId,
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: planDocumentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "Plan body",
|
||||
latestRevisionId: acceptedPlanRevisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: assigneeAgentId,
|
||||
updatedByAgentId: assigneeAgentId,
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: acceptedPlanRevisionId,
|
||||
companyId,
|
||||
documentId: planDocumentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "Plan body",
|
||||
createdByAgentId: assigneeAgentId,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
documentId: planDocumentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: acceptedInteractionId,
|
||||
companyId,
|
||||
issueId: sourceIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: sourceIssueId,
|
||||
documentId: planDocumentId,
|
||||
key: "plan",
|
||||
revisionId: acceptedPlanRevisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
},
|
||||
resolvedAt: new Date(),
|
||||
createdByUserId: "local-board",
|
||||
resolvedByUserId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId };
|
||||
}
|
||||
|
||||
async function getAcceptedPlanClaim(sourceIssueId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issuePlanDecompositions)
|
||||
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
it("reuses the same child issue set on repeat decomposition attempts for an accepted plan revision", async () => {
|
||||
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
|
||||
const children = [
|
||||
{
|
||||
title: "Implement the claim table",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
title: "Add decomposition route tests",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const first = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
expect(first.decomposition).not.toHaveProperty("requestedChildren");
|
||||
expect(first.childIssueIds).toHaveLength(2);
|
||||
expect(first.newlyCreatedIssues).toHaveLength(2);
|
||||
expect(first.decomposition.status).toBe("completed");
|
||||
|
||||
const second = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
expect(second.childIssueIds).toEqual(first.childIssueIds);
|
||||
expect(second.newlyCreatedIssues).toHaveLength(0);
|
||||
expect(second.decomposition.status).toBe("completed");
|
||||
|
||||
const persistedClaims = await db
|
||||
.select()
|
||||
.from(issuePlanDecompositions)
|
||||
.where(eq(issuePlanDecompositions.sourceIssueId, sourceIssueId));
|
||||
expect(persistedClaims).toHaveLength(1);
|
||||
expect(persistedClaims[0]?.requestedChildCount).toBe(2);
|
||||
expect(persistedClaims[0]?.childIssueIds).toEqual(first.childIssueIds);
|
||||
|
||||
const childrenRows = await db
|
||||
.select({ id: issues.id, title: issues.title })
|
||||
.from(issues)
|
||||
.where(eq(issues.parentId, sourceIssueId));
|
||||
expect(childrenRows).toHaveLength(2);
|
||||
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
||||
|
||||
const companyIssues = await svc.list(companyId, { parentId: sourceIssueId });
|
||||
expect(companyIssues).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects a different child set for the same accepted plan fingerprint", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
|
||||
await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Implement the claim table",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
await expect(svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Implement the claim table",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
title: "This duplicate should be rejected",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows accepted-plan decomposition on a standard-work issue with an accepted plan document", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue({
|
||||
workMode: "standard",
|
||||
issueTitle: "Implement after planning",
|
||||
});
|
||||
|
||||
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Implement the approved first slice",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
expect(result.childIssueIds).toHaveLength(1);
|
||||
expect(result.newlyCreatedIssues).toHaveLength(1);
|
||||
expect(result.decomposition.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("serializes concurrent accepted-plan retries for the same parent issue without duplicate children", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
const children = [
|
||||
{
|
||||
title: "Persist exact-once decomposition claim",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
{
|
||||
title: "Guard concurrent retry callers",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
||||
expect(claim).not.toBeNull();
|
||||
|
||||
for (const childIssueId of initial.childIssueIds) {
|
||||
await db.delete(issues).where(eq(issues.id, childIssueId));
|
||||
}
|
||||
await db
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: "in_flight",
|
||||
childIssueIds: [],
|
||||
completedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issuePlanDecompositions.id, claim!.id));
|
||||
|
||||
const svcA = issueService(db);
|
||||
const svcB = issueService(db);
|
||||
const [first, second] = await Promise.all([
|
||||
svcA.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
}),
|
||||
svcB.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(first.childIssueIds).toEqual(second.childIssueIds);
|
||||
expect(first.childIssueIds).toHaveLength(2);
|
||||
expect(first.newlyCreatedIssues.length + second.newlyCreatedIssues.length).toBe(2);
|
||||
|
||||
const persistedClaim = await getAcceptedPlanClaim(sourceIssueId);
|
||||
expect(persistedClaim?.status).toBe("completed");
|
||||
expect(persistedClaim?.childIssueIds).toEqual(first.childIssueIds);
|
||||
|
||||
const childrenRows = await db
|
||||
.select({ id: issues.id, title: issues.title })
|
||||
.from(issues)
|
||||
.where(eq(issues.parentId, sourceIssueId));
|
||||
expect(childrenRows).toHaveLength(2);
|
||||
expect(childrenRows.map((row) => row.id).sort()).toEqual([...first.childIssueIds].sort());
|
||||
});
|
||||
|
||||
it("rejects another planning parent's accepted revision even when both issues share the assignee", async () => {
|
||||
const { companyId, goalId, assigneeAgentId } = await seedAcceptedPlanContext();
|
||||
const firstIssue = await seedAcceptedPlanIssue({
|
||||
companyId,
|
||||
goalId,
|
||||
assigneeAgentId,
|
||||
issueTitle: "Earlier accepted plan",
|
||||
});
|
||||
const secondIssue = await seedAcceptedPlanIssue({
|
||||
companyId,
|
||||
goalId,
|
||||
assigneeAgentId,
|
||||
issueTitle: "Later accepted plan",
|
||||
});
|
||||
|
||||
await svc.decomposeAcceptedPlan(firstIssue.sourceIssueId, {
|
||||
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Decompose the first issue only",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
await expect(svc.decomposeAcceptedPlan(secondIssue.sourceIssueId, {
|
||||
acceptedPlanRevisionId: firstIssue.acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "This must not land on the second parent",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
})).rejects.toMatchObject({
|
||||
status: 422,
|
||||
});
|
||||
|
||||
const secondIssueChildren = await db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(eq(issues.parentId, secondIssue.sourceIssueId));
|
||||
expect(secondIssueChildren).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resumes partial child creation under the claimed fingerprint without duplicating completed children", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
const children = [
|
||||
{
|
||||
title: "Create the first child once",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
{
|
||||
title: "Recreate only the missing tail child",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
||||
expect(claim).not.toBeNull();
|
||||
|
||||
const [firstChildId, secondChildId] = initial.childIssueIds;
|
||||
expect(firstChildId).toBeTruthy();
|
||||
expect(secondChildId).toBeTruthy();
|
||||
|
||||
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
||||
await db
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: "in_flight",
|
||||
childIssueIds: [firstChildId!],
|
||||
completedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issuePlanDecompositions.id, claim!.id));
|
||||
|
||||
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
expect(retried.decomposition.status).toBe("completed");
|
||||
expect(retried.childIssueIds[0]).toBe(firstChildId);
|
||||
expect(retried.newlyCreatedIssues).toHaveLength(1);
|
||||
expect(retried.newlyCreatedIssues[0]?.title).toBe("Recreate only the missing tail child");
|
||||
|
||||
const childrenRows = await db
|
||||
.select({ id: issues.id, title: issues.title })
|
||||
.from(issues)
|
||||
.where(eq(issues.parentId, sourceIssueId));
|
||||
expect(childrenRows).toHaveLength(2);
|
||||
expect(childrenRows.some((row) => row.id === firstChildId)).toBe(true);
|
||||
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("preserves the existing live claim owner when another actor resumes the same fingerprint", async () => {
|
||||
const { companyId, sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
const competingAgentId = randomUUID();
|
||||
const liveOwnerRunId = randomUUID();
|
||||
const competingRunId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: competingAgentId,
|
||||
companyId,
|
||||
name: "SecondCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: liveOwnerRunId,
|
||||
companyId,
|
||||
agentId: assigneeAgentId,
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
},
|
||||
{
|
||||
id: competingRunId,
|
||||
companyId,
|
||||
agentId: competingAgentId,
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
},
|
||||
]);
|
||||
|
||||
const children = [
|
||||
{
|
||||
title: "Keep the first created child",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
{
|
||||
title: "Create the missing second child",
|
||||
status: "todo" as const,
|
||||
workMode: "standard" as const,
|
||||
priority: "medium" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const initial = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: assigneeAgentId,
|
||||
actorRunId: liveOwnerRunId,
|
||||
});
|
||||
const [firstChildId, secondChildId] = initial.childIssueIds;
|
||||
const claim = await getAcceptedPlanClaim(sourceIssueId);
|
||||
|
||||
await db.delete(issues).where(eq(issues.id, secondChildId!));
|
||||
await db
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: "in_flight",
|
||||
childIssueIds: [firstChildId!],
|
||||
completedAt: null,
|
||||
ownerAgentId: assigneeAgentId,
|
||||
ownerRunId: liveOwnerRunId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issuePlanDecompositions.id, claim!.id));
|
||||
|
||||
const retried = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children,
|
||||
actorAgentId: competingAgentId,
|
||||
actorRunId: competingRunId,
|
||||
});
|
||||
|
||||
expect(retried.decomposition.status).toBe("completed");
|
||||
expect(retried.decomposition.ownerAgentId).toBe(assigneeAgentId);
|
||||
expect(retried.decomposition.ownerRunId).toBe(liveOwnerRunId);
|
||||
});
|
||||
|
||||
it("lists persisted decompositions with child issue summaries", async () => {
|
||||
const { sourceIssueId, acceptedPlanRevisionId, assigneeAgentId } = await seedAcceptedPlanIssue();
|
||||
|
||||
const initial = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
||||
expect(initial).toEqual([]);
|
||||
|
||||
const result = await svc.decomposeAcceptedPlan(sourceIssueId, {
|
||||
acceptedPlanRevisionId,
|
||||
children: [
|
||||
{
|
||||
title: "Surface decomposition status in operator UI",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
{
|
||||
title: "Add regression coverage",
|
||||
status: "todo",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
actorAgentId: assigneeAgentId,
|
||||
});
|
||||
|
||||
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssueId);
|
||||
expect(decompositions).toHaveLength(1);
|
||||
const [record] = decompositions;
|
||||
expect(record?.status).toBe("completed");
|
||||
expect(record?.acceptedPlanRevisionId).toBe(acceptedPlanRevisionId);
|
||||
expect(record?.acceptedPlanRevisionNumber).toBeTypeOf("number");
|
||||
expect(record?.childIssues.map((child) => child.id).sort()).toEqual(
|
||||
[...result.childIssueIds].sort(),
|
||||
);
|
||||
expect(record).not.toHaveProperty("requestedChildren");
|
||||
expect(record?.childIssues.every((child) => typeof child.title === "string")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue