mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +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
|
|
@ -37,6 +37,7 @@ import {
|
|||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueComments,
|
||||
issuePlanDecompositions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
|
|
@ -1933,6 +1934,59 @@ function normalizeInteractionContinuationWakeContext(
|
|||
clearInteractionContinuationWakeContext(contextSnapshot);
|
||||
}
|
||||
|
||||
type AcceptedPlanWakeRoutingDecision = {
|
||||
otherActiveClaimIssueId: string;
|
||||
otherActiveClaimIdentifier: string | null;
|
||||
otherActiveClaimTitle: string;
|
||||
forceFreshSession: boolean;
|
||||
suppressAcceptedContinuation: boolean;
|
||||
};
|
||||
|
||||
async function resolveAcceptedPlanWakeRoutingDecision(args: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string | null;
|
||||
acceptedPlanContinuationWake: boolean;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
}): Promise<AcceptedPlanWakeRoutingDecision | null> {
|
||||
if (args.issueId === null) return null;
|
||||
if (!args.acceptedPlanContinuationWake) return null;
|
||||
|
||||
const activeClaims = await args.db
|
||||
.select({
|
||||
sourceIssueId: issuePlanDecompositions.sourceIssueId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
})
|
||||
.from(issuePlanDecompositions)
|
||||
.innerJoin(issues, eq(issues.id, issuePlanDecompositions.sourceIssueId))
|
||||
.where(and(
|
||||
eq(issuePlanDecompositions.companyId, args.companyId),
|
||||
eq(issuePlanDecompositions.ownerAgentId, args.agentId),
|
||||
eq(issuePlanDecompositions.status, "in_flight"),
|
||||
))
|
||||
.orderBy(desc(issuePlanDecompositions.updatedAt), asc(issuePlanDecompositions.createdAt));
|
||||
|
||||
if (activeClaims.length === 0) return null;
|
||||
if (activeClaims.some((claim) => claim.sourceIssueId === args.issueId)) return null;
|
||||
|
||||
const otherActiveClaim = activeClaims[0];
|
||||
if (!otherActiveClaim) return null;
|
||||
|
||||
const hasAcceptedContinuationWake =
|
||||
readNonEmptyString(args.contextSnapshot.interactionKind) === "request_confirmation" &&
|
||||
readNonEmptyString(args.contextSnapshot.interactionStatus) === "accepted";
|
||||
|
||||
return {
|
||||
otherActiveClaimIssueId: otherActiveClaim.sourceIssueId,
|
||||
otherActiveClaimIdentifier: otherActiveClaim.identifier ?? null,
|
||||
otherActiveClaimTitle: otherActiveClaim.title,
|
||||
forceFreshSession: true,
|
||||
suppressAcceptedContinuation: hasAcceptedContinuationWake,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCoalescedContextSnapshot(
|
||||
existingRaw: unknown,
|
||||
incoming: Record<string, unknown>,
|
||||
|
|
@ -2229,6 +2283,7 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
kind?: string | null;
|
||||
status?: string | null;
|
||||
} | null;
|
||||
acceptedPlanContinuation?: boolean;
|
||||
}) {
|
||||
const quoteTaskScalar = (value: string) => JSON.stringify(value);
|
||||
const fenceTaskText = (value: string) => {
|
||||
|
|
@ -2243,8 +2298,11 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
const wakeComment = input.wakeComment ?? null;
|
||||
const acceptedPlanContinuation =
|
||||
!wakeComment &&
|
||||
input.interaction?.kind === "request_confirmation" &&
|
||||
input.interaction.status === "accepted";
|
||||
(input.acceptedPlanContinuation || (
|
||||
input.interaction?.kind === "request_confirmation" &&
|
||||
input.interaction.status === "accepted" &&
|
||||
issue?.workMode === "planning"
|
||||
));
|
||||
if (!issue && !wakeComment) return null;
|
||||
|
||||
const lines = [
|
||||
|
|
@ -2270,6 +2328,12 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
"Planning mode directive:",
|
||||
directive,
|
||||
);
|
||||
} else if (acceptedPlanContinuation) {
|
||||
lines.push(
|
||||
"",
|
||||
"Accepted plan directive:",
|
||||
"Create child issues from the approved plan only. Do not write code or perform implementation work on the source issue.",
|
||||
);
|
||||
}
|
||||
const description = issue.description?.trim();
|
||||
if (description) {
|
||||
|
|
@ -7055,6 +7119,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const acceptedPlanWakeRoutingDecision = issueContext
|
||||
? await resolveAcceptedPlanWakeRoutingDecision({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
acceptedPlanContinuationWake:
|
||||
readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation"
|
||||
|| (
|
||||
issueContext.workMode === "planning"
|
||||
&& readNonEmptyString(context.interactionKind) === "request_confirmation"
|
||||
&& readNonEmptyString(context.interactionStatus) === "accepted"
|
||||
),
|
||||
contextSnapshot: context,
|
||||
})
|
||||
: null;
|
||||
if (acceptedPlanWakeRoutingDecision) {
|
||||
context.forceFreshSession = true;
|
||||
context.acceptedPlanWakeRouting = {
|
||||
reason: "other_issue_claim_in_flight",
|
||||
otherActiveClaimIssueId: acceptedPlanWakeRoutingDecision.otherActiveClaimIssueId,
|
||||
otherActiveClaimIdentifier: acceptedPlanWakeRoutingDecision.otherActiveClaimIdentifier,
|
||||
otherActiveClaimTitle: acceptedPlanWakeRoutingDecision.otherActiveClaimTitle,
|
||||
};
|
||||
if (acceptedPlanWakeRoutingDecision.suppressAcceptedContinuation) {
|
||||
clearInteractionContinuationWakeContext(context);
|
||||
delete context.workspaceRefreshReason;
|
||||
}
|
||||
} else {
|
||||
delete context.acceptedPlanWakeRouting;
|
||||
}
|
||||
const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext);
|
||||
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
|
||||
|
|
@ -7154,6 +7249,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
kind: readNonEmptyString(context.interactionKind),
|
||||
status: readNonEmptyString(context.interactionStatus),
|
||||
},
|
||||
acceptedPlanContinuation:
|
||||
readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation"
|
||||
&& !parseObject(context.acceptedPlanWakeRouting),
|
||||
});
|
||||
if (issueRef) {
|
||||
context.paperclipIssue = {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
||||
|
|
@ -54,6 +55,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
return {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -29,6 +32,7 @@ import {
|
|||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AcceptedPlanDecomposition,
|
||||
IssueCommentAuthorType,
|
||||
IssueCommentMetadata,
|
||||
IssueCommentPresentation,
|
||||
|
|
@ -245,6 +249,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;
|
||||
|
|
@ -284,6 +289,30 @@ type IssueLastActivityStat = {
|
|||
latestCommentAt: Date | null;
|
||||
latestLogAt: Date | null;
|
||||
};
|
||||
|
||||
function serializeAcceptedPlanDecomposition(
|
||||
decomposition: IssuePlanDecompositionRow,
|
||||
): AcceptedPlanDecomposition {
|
||||
return {
|
||||
id: decomposition.id,
|
||||
companyId: decomposition.companyId,
|
||||
sourceIssueId: decomposition.sourceIssueId,
|
||||
acceptedPlanRevisionId: decomposition.acceptedPlanRevisionId,
|
||||
acceptedInteractionId: decomposition.acceptedInteractionId,
|
||||
status: decomposition.status as AcceptedPlanDecomposition["status"],
|
||||
requestFingerprint: decomposition.requestFingerprint,
|
||||
// Intentionally omit requestedChildren here; the API only needs stable counts
|
||||
// and child ids, while the durable table keeps the full child draft payload.
|
||||
requestedChildCount: decomposition.requestedChildCount,
|
||||
childIssueIds: normalizeIssuePlanDecompositionChildIds(decomposition.childIssueIds),
|
||||
ownerAgentId: decomposition.ownerAgentId,
|
||||
ownerUserId: decomposition.ownerUserId,
|
||||
ownerRunId: decomposition.ownerRunId,
|
||||
completedAt: decomposition.completedAt,
|
||||
createdAt: decomposition.createdAt,
|
||||
updatedAt: decomposition.updatedAt,
|
||||
};
|
||||
}
|
||||
type IssueUserContextInput = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
|
|
@ -303,6 +332,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 +415,167 @@ 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);
|
||||
}
|
||||
|
||||
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({
|
||||
acceptedPlanRevisionId: input.acceptedPlanRevisionId,
|
||||
children: input.children.map(normalizeAcceptedPlanDecompositionFingerprintChild),
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
export 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 resolveAcceptedPlanClaimOwner(input: {
|
||||
dbOrTx: Pick<Db, "select">;
|
||||
claim: Pick<typeof issuePlanDecompositions.$inferSelect, "ownerAgentId" | "ownerUserId" | "ownerRunId">;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
}) {
|
||||
const nextOwner = {
|
||||
ownerAgentId: input.actorAgentId ?? null,
|
||||
ownerUserId: input.actorUserId ?? null,
|
||||
ownerRunId: input.actorRunId ?? null,
|
||||
};
|
||||
if (
|
||||
input.claim.ownerAgentId === nextOwner.ownerAgentId
|
||||
&& input.claim.ownerUserId === nextOwner.ownerUserId
|
||||
&& input.claim.ownerRunId === nextOwner.ownerRunId
|
||||
) {
|
||||
return nextOwner;
|
||||
}
|
||||
|
||||
if (!input.claim.ownerRunId) {
|
||||
return nextOwner;
|
||||
}
|
||||
|
||||
const existingOwnerRun = await input.dbOrTx
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, input.claim.ownerRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingOwnerRun && !TERMINAL_HEARTBEAT_RUN_STATUSES.has(existingOwnerRun.status)) {
|
||||
return {
|
||||
ownerAgentId: input.claim.ownerAgentId,
|
||||
ownerUserId: input.claim.ownerUserId,
|
||||
ownerRunId: input.claim.ownerRunId,
|
||||
};
|
||||
}
|
||||
|
||||
return nextOwner;
|
||||
}
|
||||
|
||||
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 +4258,278 @@ 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,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, sourceIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceIssue) throw notFound("Source issue not found");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return existing;
|
||||
});
|
||||
|
||||
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 ownerPatch = await resolveAcceptedPlanClaimOwner({
|
||||
dbOrTx: tx,
|
||||
claim,
|
||||
actorAgentId: data.actorAgentId,
|
||||
actorUserId: data.actorUserId,
|
||||
actorRunId: data.actorRunId,
|
||||
});
|
||||
const [completed] = await tx
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: "completed",
|
||||
childIssueIds: nextIds,
|
||||
completedAt,
|
||||
...ownerPatch,
|
||||
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 ownerPatch = await resolveAcceptedPlanClaimOwner({
|
||||
dbOrTx: tx,
|
||||
claim,
|
||||
actorAgentId: data.actorAgentId,
|
||||
actorUserId: data.actorUserId,
|
||||
actorRunId: data.actorRunId,
|
||||
});
|
||||
const [updatedClaim] = await tx
|
||||
.update(issuePlanDecompositions)
|
||||
.set({
|
||||
status: nextStatus,
|
||||
childIssueIds: nextIds,
|
||||
completedAt: nextStatus === "completed" ? now : null,
|
||||
...ownerPatch,
|
||||
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));
|
||||
|
||||
const decomposition = serializeAcceptedPlanDecomposition(currentClaim);
|
||||
|
||||
return {
|
||||
decomposition,
|
||||
childIssueIds: decomposition.childIssueIds,
|
||||
childIssues: orderedChildIssues,
|
||||
newlyCreatedIssues,
|
||||
};
|
||||
},
|
||||
|
||||
listAcceptedPlanDecompositions: async (sourceIssueId: string) => {
|
||||
const sourceIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, sourceIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceIssue) return [];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
decomposition: issuePlanDecompositions,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
})
|
||||
.from(issuePlanDecompositions)
|
||||
.leftJoin(
|
||||
documentRevisions,
|
||||
eq(documentRevisions.id, issuePlanDecompositions.acceptedPlanRevisionId),
|
||||
)
|
||||
.where(and(
|
||||
eq(issuePlanDecompositions.companyId, sourceIssue.companyId),
|
||||
eq(issuePlanDecompositions.sourceIssueId, sourceIssue.id),
|
||||
))
|
||||
.orderBy(desc(issuePlanDecompositions.createdAt));
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const allChildIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const childId of normalizeIssuePlanDecompositionChildIds(row.decomposition.childIssueIds)) {
|
||||
allChildIds.add(childId);
|
||||
}
|
||||
}
|
||||
|
||||
const childIssueRows = allChildIds.size > 0
|
||||
? await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, sourceIssue.companyId), inArray(issues.id, Array.from(allChildIds))))
|
||||
: [];
|
||||
const childIssueMap = new Map(childIssueRows.map((row) => [row.id, row]));
|
||||
|
||||
return rows.map((row) => {
|
||||
const decomposition = serializeAcceptedPlanDecomposition(row.decomposition);
|
||||
const childIds = decomposition.childIssueIds;
|
||||
return {
|
||||
...decomposition,
|
||||
acceptedPlanRevisionNumber: row.revisionNumber ?? null,
|
||||
childIssues: childIds
|
||||
.map((childId) => childIssueMap.get(childId) ?? null)
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
data: IssueCreateInput,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue