Add exact-once accepted plan decomposition claims

This commit is contained in:
Devin Foley committed 2026-05-23 21:40:59 -07:00
1 parent 6b53e1bf7d
commit ca0407cbb7
13 files changed
+19193 -1

No files matched your search

@@ -0,0 +1,26 @@
CREATE TABLE "issue_plan_decompositions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"source_issue_id" uuid NOT NULL,
"accepted_plan_revision_id" uuid NOT NULL,
"accepted_interaction_id" uuid,
"status" text DEFAULT 'in_flight' NOT NULL,
"request_fingerprint" text NOT NULL,
"requested_child_count" integer DEFAULT 0 NOT NULL,
"requested_children" jsonb DEFAULT '[]'::jsonb NOT NULL,
"child_issue_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"owner_agent_id" uuid,
"owner_user_id" text,
"owner_run_id" uuid,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk" FOREIGN KEY ("accepted_plan_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk" FOREIGN KEY ("accepted_interaction_id") REFERENCES "public"."issue_thread_interactions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_plan_decompositions" ADD CONSTRAINT "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("owner_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_plan_decompositions_company_source_status_idx" ON "issue_plan_decompositions" USING btree ("company_id","source_issue_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_plan_decompositions_source_revision_uq" ON "issue_plan_decompositions" USING btree ("company_id","source_issue_id","accepted_plan_revision_id");
File diff suppressed because it is too large. Load diff
@@ -631,6 +631,13 @@
"when": 1779129600000,
"tag": "0089_cloud_upstreams",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1779597488245,
"tag": "0090_demonic_grey_gargoyle",
"breakpoints": true
}
]
}
}
+1
View File
@@ -30,6 +30,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { issues } from "./issues.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js";
export { issueReferenceMentions } from "./issue_reference_mentions.js";
export { issueRelations } from "./issue_relations.js";
@@ -0,0 +1,45 @@
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, integer, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { documentRevisions } from "./document_revisions.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issueThreadInteractions } from "./issue_thread_interactions.js";
import { issues } from "./issues.js";
export const issuePlanDecompositions = pgTable(
"issue_plan_decompositions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
acceptedPlanRevisionId: uuid("accepted_plan_revision_id")
.notNull()
.references(() => documentRevisions.id, { onDelete: "cascade" }),
acceptedInteractionId: uuid("accepted_interaction_id")
.references(() => issueThreadInteractions.id, { onDelete: "set null" }),
status: text("status").notNull().default("in_flight"),
requestFingerprint: text("request_fingerprint").notNull(),
requestedChildCount: integer("requested_child_count").notNull().default(0),
requestedChildren: jsonb("requested_children").$type<Record<string, unknown>[]>().notNull().default(sql`'[]'::jsonb`),
childIssueIds: jsonb("child_issue_ids").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
ownerUserId: text("owner_user_id"),
ownerRunId: uuid("owner_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySourceStatusIdx: index("issue_plan_decompositions_company_source_status_idx").on(
table.companyId,
table.sourceIssueId,
table.status,
),
sourceRevisionUq: uniqueIndex("issue_plan_decompositions_source_revision_uq").on(
table.companyId,
table.sourceIssueId,
table.acceptedPlanRevisionId,
),
}),
);
+6
View File
@@ -438,6 +438,10 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
AcceptedPlanDecompositionResult,
IssueThreadInteractionBase,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
@@ -793,6 +797,7 @@ export {
createIssueSchema,
createIssueInputSchema,
createChildIssueSchema,
createAcceptedPlanDecompositionSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
issueBlockedInboxAttentionSchema,
@@ -861,6 +866,7 @@ export {
releaseIssueTreeHoldSchema,
type CreateIssue,
type CreateChildIssue,
type CreateAcceptedPlanDecomposition,
type CreateIssueLabel,
type UpdateIssue,
type ResolveIssueRecoveryAction,
+4
View File
@@ -207,6 +207,10 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
AcceptedPlanDecompositionResult,
IssueThreadInteractionBase,
SuggestTasksInteraction,
AskUserQuestionsInteraction,
+50
View File
@@ -129,6 +129,56 @@ export interface LegacyPlanDocument {
source: "issue_description";
}
export type AcceptedPlanDecompositionStatus = "in_flight" | "completed";
export interface AcceptedPlanDecompositionChild {
projectId?: string | null;
projectWorkspaceId?: string | null;
goalId?: string | null;
blockedByIssueIds?: string[];
title: string;
description?: string | null;
status: IssueStatus;
workMode: IssueWorkMode;
priority: IssuePriority;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
executionPolicy?: IssueExecutionPolicy | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
labelIds?: string[];
acceptanceCriteria?: string[];
blockParentUntilDone?: boolean;
}
export interface AcceptedPlanDecomposition {
id: string;
companyId: string;
sourceIssueId: string;
acceptedPlanRevisionId: string;
acceptedInteractionId: string | null;
status: AcceptedPlanDecompositionStatus;
requestFingerprint: string;
requestedChildCount: number;
childIssueIds: string[];
ownerAgentId: string | null;
ownerUserId: string | null;
ownerRunId: string | null;
completedAt: Date | string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface AcceptedPlanDecompositionResult {
decomposition: AcceptedPlanDecomposition;
childIssueIds: string[];
newlyCreatedChildIssueIds: string[];
}
export interface IssueRelationIssueSummary {
id: string;
identifier: string | null;
+2
View File
@@ -151,6 +151,7 @@ export {
createIssueSchema,
createIssueInputSchema,
createChildIssueSchema,
createAcceptedPlanDecompositionSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
issueBlockedInboxAttentionSchema,
@@ -202,6 +203,7 @@ export {
restoreIssueDocumentRevisionSchema,
type CreateIssue,
type CreateChildIssue,
type CreateAcceptedPlanDecomposition,
type CreateIssueLabel,
type UpdateIssue,
type IssueExecutionWorkspaceSettings,
+7
View File
@@ -412,6 +412,13 @@ export const createChildIssueSchema = withCreateIssueStatusDefault(createIssueBa
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
export const createAcceptedPlanDecompositionSchema = z.object({
acceptedPlanRevisionId: z.string().uuid(),
children: z.array(createChildIssueSchema).min(1).max(25),
});
export type CreateAcceptedPlanDecomposition = z.infer<typeof createAcceptedPlanDecompositionSchema>;
export const createIssueLabelSchema = z.object({
name: z.string().trim().min(1).max(48),
color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"),
+240
View File
@@ -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,238 @@ 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 seedAcceptedPlanIssue() {
const companyId = randomUUID();
const goalId = randomUUID();
const sourceIssueId = randomUUID();
const planDocumentId = randomUUID();
const acceptedPlanRevisionId = randomUUID();
const acceptedInteractionId = 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",
});
await db.insert(issues).values({
id: sourceIssueId,
companyId,
goalId,
title: "Planning issue",
status: "in_progress",
priority: "medium",
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 };
}
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.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,
});
});
});
+134
View File
@@ -22,6 +22,7 @@ import {
createIssueThreadInteractionSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
createAcceptedPlanDecompositionSchema,
checkoutIssueSchema,
createChildIssueSchema,
createIssueSchema,
@@ -3332,6 +3333,139 @@ export function issueRoutes(
res.status(201).json(issue);
});
router.post("/issues/:id/accepted-plan-decompositions", validate(createAcceptedPlanDecompositionSchema), async (req, res) => {
const sourceIssueId = req.params.id as string;
const sourceIssue = await svc.getById(sourceIssueId);
if (!sourceIssue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, sourceIssue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return;
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(child));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, child))) return;
if (child.assigneeAgentId || child.assigneeUserId) {
await assertCanAssignTasks(req, sourceIssue.companyId, {
projectId: child.projectId ?? sourceIssue.projectId ?? null,
parentIssueId: sourceIssue.id,
assigneeAgentId: child.assigneeAgentId ?? null,
assigneeUserId: child.assigneeUserId ?? null,
});
}
await assertIssueEnvironmentSelection(sourceIssue.companyId, child.executionWorkspaceSettings?.environmentId);
}
const actor = getActorInfo(req);
const normalizedChildren = req.body.children.map((child: typeof req.body.children[number]) => {
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(child.executionPolicy),
actor.actorType,
);
assertCanManageIssueMonitor(req, child.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
return {
...child,
executionPolicy,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
};
});
const result = await svc.decomposeAcceptedPlan(sourceIssue.id, {
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
children: normalizedChildren,
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
actorRunId: actor.runId ?? null,
});
await logActivity(db, {
companyId: sourceIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.accepted_plan_decomposition_updated",
entityType: "issue",
entityId: sourceIssue.id,
details: {
identifier: sourceIssue.identifier,
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
decompositionId: result.decomposition.id,
status: result.decomposition.status,
requestedChildCount: req.body.children.length,
childIssueIds: result.childIssueIds,
newlyCreatedChildIssueIds: result.newlyCreatedIssues.map((issue) => issue.id),
},
});
for (const issue of result.newlyCreatedIssues) {
await logActivity(db, {
companyId: sourceIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.child_created",
entityType: "issue",
entityId: issue.id,
details: {
parentId: sourceIssue.id,
identifier: issue.identifier,
title: issue.title,
inheritedExecutionWorkspaceFromIssueId: sourceIssue.id,
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
...buildCreateIssueActivityStatusDetails(issue, res),
},
});
const executionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy);
if (executionPolicy?.monitor) {
await logActivity(db, {
companyId: sourceIssue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.monitor_scheduled",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
parentId: sourceIssue.id,
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
nextCheckAt: executionPolicy.monitor.nextCheckAt,
notes: executionPolicy.monitor.notes,
scheduledBy: executionPolicy.monitor.scheduledBy,
serviceName: executionPolicy.monitor.serviceName ?? null,
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
},
});
}
void queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "issue_assigned",
mutation: "accepted_plan_decomposition",
contextSource: "issue.accepted_plan_decomposition",
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
}
res.json({
decomposition: result.decomposition,
childIssueIds: result.childIssueIds,
newlyCreatedChildIssueIds: result.newlyCreatedIssues.map((issue) => issue.id),
});
});
router.post("/issues/:id/monitor/check-now", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
+318
View File
@@ -1,4 +1,5 @@
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -9,6 +10,7 @@ import {
assets,
companies,
companyMemberships,
documentRevisions,
documents,
goals,
heartbeatRuns,
@@ -17,6 +19,7 @@ import {
issueAttachments,
issueInboxArchives,
issueLabels,
issuePlanDecompositions,
issueRecoveryActions,
issueRelations,
issueComments,
@@ -245,6 +248,7 @@ export interface IssueFilters {
type IssueRow = typeof issues.$inferSelect;
type IssueLabelRow = typeof labels.$inferSelect;
type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect;
type IssueActiveRunRow = {
id: string;
status: string;
@@ -303,6 +307,16 @@ type IssueChildCreateInput = IssueCreateInput & {
actorAgentId?: string | null;
actorUserId?: string | null;
};
type AcceptedPlanDecompositionInput = {
acceptedPlanRevisionId: string;
children: IssueChildCreateInput[];
actorAgentId?: string | null;
actorUserId?: string | null;
actorRunId?: string | null;
};
type AcceptedPlanDocumentInteraction = {
id: string;
};
type IssueRelationSummaryMap = {
blockedBy: IssueRelationIssueSummary[];
blocks: IssueRelationIssueSummary[];
@@ -376,6 +390,96 @@ function appendAcceptanceCriteriaToDescription(description: string | null | unde
return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown;
}
function normalizeAcceptedPlanDecompositionFingerprintValue(value: unknown): unknown {
if (value === undefined) return null;
if (
value == null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) {
return value.map((item) => normalizeAcceptedPlanDecompositionFingerprintValue(item));
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.sort()
.map((key) => [key, normalizeAcceptedPlanDecompositionFingerprintValue(record[key])]),
);
}
return String(value);
}
function createAcceptedPlanDecompositionRequestFingerprint(input: {
acceptedPlanRevisionId: string;
children: IssueChildCreateInput[];
}) {
const canonical = JSON.stringify(normalizeAcceptedPlanDecompositionFingerprintValue(input));
return createHash("sha256").update(canonical).digest("hex");
}
function normalizeIssuePlanDecompositionChildIds(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function readAcceptedPlanConfirmationTarget(payload: unknown): {
revisionId: string;
key: string;
issueId: string;
} | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const target = (payload as Record<string, unknown>).target;
if (!target || typeof target !== "object" || Array.isArray(target)) return null;
const record = target as Record<string, unknown>;
if (record.type !== "issue_document") return null;
const revisionId = readStringFromRecord(record, "revisionId");
const key = readStringFromRecord(record, "key");
const issueId = readStringFromRecord(record, "issueId");
if (!revisionId || !key || !issueId) return null;
return { revisionId, key, issueId };
}
async function findAcceptedPlanDocumentInteraction(
dbOrTx: Pick<Db, "select">,
input: {
companyId: string;
sourceIssueId: string;
acceptedPlanRevisionId: string;
},
): Promise<AcceptedPlanDocumentInteraction | null> {
const rows = await dbOrTx
.select({
id: issueThreadInteractions.id,
payload: issueThreadInteractions.payload,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.sourceIssueId),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "accepted"),
))
.orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt));
for (const row of rows) {
const target = readAcceptedPlanConfirmationTarget(row.payload);
if (
target?.issueId === input.sourceIssueId &&
target.key === "plan" &&
target.revisionId === input.acceptedPlanRevisionId
) {
return { id: row.id };
}
}
return null;
}
function createIssueDependencyReadiness(issueId: string): IssueDependencyReadiness {
return {
issueId,
@@ -4058,6 +4162,220 @@ export function issueService(db: Db) {
};
},
decomposeAcceptedPlan: async (
sourceIssueId: string,
data: AcceptedPlanDecompositionInput,
) => {
const sourceIssue = await db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
goalId: issues.goalId,
workMode: issues.workMode,
})
.from(issues)
.where(eq(issues.id, sourceIssueId))
.then((rows) => rows[0] ?? null);
if (!sourceIssue) throw notFound("Source issue not found");
if (sourceIssue.workMode !== "planning") {
throw unprocessable("Accepted-plan decomposition is only available for planning issues");
}
const requestFingerprint = createAcceptedPlanDecompositionRequestFingerprint({
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
children: data.children,
});
const initialClaim = await db.transaction(async (tx) => {
await tx.execute(sql`select ${issues.id} from ${issues} where ${issues.id} = ${sourceIssue.id} for update`);
const belongsToPlanDocument = await tx
.select({ revisionId: documentRevisions.id })
.from(issueDocuments)
.innerJoin(documentRevisions, eq(issueDocuments.documentId, documentRevisions.documentId))
.where(and(
eq(issueDocuments.companyId, sourceIssue.companyId),
eq(issueDocuments.issueId, sourceIssue.id),
eq(issueDocuments.key, "plan"),
eq(documentRevisions.id, data.acceptedPlanRevisionId),
))
.then((rows) => rows[0] ?? null);
if (!belongsToPlanDocument) {
throw unprocessable("acceptedPlanRevisionId must belong to the source issue's plan document");
}
const acceptedInteraction = await findAcceptedPlanDocumentInteraction(tx, {
companyId: sourceIssue.companyId,
sourceIssueId: sourceIssue.id,
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
});
if (!acceptedInteraction) {
throw unprocessable("acceptedPlanRevisionId must have an accepted plan confirmation");
}
const existing = await tx
.select()
.from(issuePlanDecompositions)
.where(and(
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
eq(issuePlanDecompositions.acceptedPlanRevisionId, data.acceptedPlanRevisionId),
))
.then((rows) => rows[0] ?? null);
const now = new Date();
if (!existing) {
const [created] = await tx
.insert(issuePlanDecompositions)
.values({
companyId: sourceIssue.companyId,
sourceIssueId: sourceIssue.id,
acceptedPlanRevisionId: data.acceptedPlanRevisionId,
acceptedInteractionId: acceptedInteraction.id,
status: "in_flight",
requestFingerprint,
requestedChildCount: data.children.length,
requestedChildren: data.children as unknown as Record<string, unknown>[],
childIssueIds: [],
ownerAgentId: data.actorAgentId ?? null,
ownerUserId: data.actorUserId ?? null,
ownerRunId: data.actorRunId ?? null,
updatedAt: now,
})
.returning();
if (!created) throw new Error("Failed to create accepted-plan decomposition claim");
return created;
}
if (existing.requestFingerprint !== requestFingerprint) {
throw conflict("Accepted-plan decomposition already exists for this revision with a different child set");
}
const [updated] = await tx
.update(issuePlanDecompositions)
.set({
acceptedInteractionId: existing.acceptedInteractionId ?? acceptedInteraction.id,
ownerAgentId: data.actorAgentId ?? null,
ownerUserId: data.actorUserId ?? null,
ownerRunId: data.actorRunId ?? null,
updatedAt: now,
})
.where(eq(issuePlanDecompositions.id, existing.id))
.returning();
if (!updated) throw new Error("Failed to refresh accepted-plan decomposition claim");
return updated;
});
let currentClaim = initialClaim;
const newlyCreatedIssues: Array<typeof issues.$inferSelect> = [];
while (true) {
const step = await db.transaction(async (tx) => {
await tx.execute(
sql`select ${issuePlanDecompositions.id}
from ${issuePlanDecompositions}
where ${issuePlanDecompositions.id} = ${currentClaim.id}
for update`,
);
const claim = await tx
.select()
.from(issuePlanDecompositions)
.where(eq(issuePlanDecompositions.id, currentClaim.id))
.then((rows) => rows[0] ?? null);
if (!claim) throw notFound("Accepted-plan decomposition claim not found");
if (claim.requestFingerprint !== requestFingerprint) {
throw conflict("Accepted-plan decomposition already exists for this revision with a different child set");
}
const existingChildIssueIds = normalizeIssuePlanDecompositionChildIds(claim.childIssueIds);
if (claim.status === "completed" || existingChildIssueIds.length >= data.children.length) {
const nextIds = existingChildIssueIds.slice(0, data.children.length);
if (claim.status === "completed" && nextIds.length === data.children.length) {
return {
claim,
createdIssue: null,
};
}
const completedAt = claim.completedAt ?? new Date();
const [completed] = await tx
.update(issuePlanDecompositions)
.set({
status: "completed",
childIssueIds: nextIds,
completedAt,
ownerAgentId: data.actorAgentId ?? null,
ownerUserId: data.actorUserId ?? null,
ownerRunId: data.actorRunId ?? null,
updatedAt: completedAt,
})
.where(eq(issuePlanDecompositions.id, claim.id))
.returning();
if (!completed) throw new Error("Failed to complete accepted-plan decomposition claim");
return {
claim: completed,
createdIssue: null,
};
}
const nextChildInput = data.children[existingChildIssueIds.length];
if (!nextChildInput) {
throw new Error("Accepted-plan decomposition child cursor moved past the requested children");
}
const createdChild = await issueService(tx as unknown as Db).createChild(sourceIssue.id, nextChildInput);
const nextIds = [...existingChildIssueIds, createdChild.issue.id];
const now = new Date();
const nextStatus = nextIds.length === data.children.length ? "completed" : "in_flight";
const [updatedClaim] = await tx
.update(issuePlanDecompositions)
.set({
status: nextStatus,
childIssueIds: nextIds,
completedAt: nextStatus === "completed" ? now : null,
ownerAgentId: data.actorAgentId ?? null,
ownerUserId: data.actorUserId ?? null,
ownerRunId: data.actorRunId ?? null,
updatedAt: now,
})
.where(eq(issuePlanDecompositions.id, claim.id))
.returning();
if (!updatedClaim) throw new Error("Failed to persist accepted-plan decomposition progress");
return {
claim: updatedClaim,
createdIssue: createdChild.issue,
};
});
currentClaim = step.claim;
if (step.createdIssue) {
newlyCreatedIssues.push(step.createdIssue);
}
if (step.claim.status === "completed") break;
}
const childIssueIds = normalizeIssuePlanDecompositionChildIds(currentClaim.childIssueIds);
const childIssueRows = childIssueIds.length > 0
? await db
.select()
.from(issues)
.where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, childIssueIds)))
: [];
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
const orderedChildIssues = childIssueIds
.map((childIssueId) => childIssueMap.get(childIssueId))
.filter((row): row is typeof issues.$inferSelect => Boolean(row));
return {
decomposition: currentClaim,
childIssueIds,
childIssues: orderedChildIssues,
newlyCreatedIssues,
};
},
create: async (
companyId: string,
data: IssueCreateInput,