diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index c30997293cf..71f088181fb 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -154,9 +154,9 @@ If a parent is truly waiting on a child, model that with blockers. Do not rely o ## 7. Accepted-Plan Decomposition -An accepted planning confirmation is permission to decompose one specific accepted plan revision into child issues. +An accepted plan confirmation is permission to decompose one specific accepted plan revision into child issues. -This complements the existing accepted-plan continuation rule: once a plan is accepted, the planning issue may create child implementation issues, but it must not start implementation work on the planning issue itself. +This complements the existing accepted-plan continuation rule: once a plan is accepted, the source issue may create child implementation issues, but it must not start implementation work on the source issue itself during that continuation. Paperclip must treat accepted-plan decomposition as an exact-once control-plane primitive, not as a free-floating wake that any later run may interpret again. @@ -168,10 +168,10 @@ The canonical decomposition fingerprint is: Where: -- `sourceIssueId` is the planning issue whose plan was accepted +- `sourceIssueId` is the issue whose `plan` document revision was accepted - `acceptedPlanRevisionId` is the accepted `plan` document revision -This is the product contract because the accepted revision is the thing being authorized for decomposition. Re-accepting, re-waking, or re-reading the same accepted revision must not authorize a second child tree. A later accepted revision on the same planning issue is a new fingerprint and may produce a different decomposition result. +This is the product contract because the accepted revision is the thing being authorized for decomposition. Re-accepting, re-waking, or re-reading the same accepted revision must not authorize a second child tree. A later accepted revision on the same source issue is a new fingerprint and may produce a different decomposition result. An implementation may also store the accepted interaction id, acceptance run id, or other evidence, but those values must collapse onto the same uniqueness guarantee. They must not allow a second decomposition claim for the same `(sourceIssueId, acceptedPlanRevisionId)` pair. @@ -196,16 +196,16 @@ If a run creates some children and then dies, retries must continue from the sam ### Parent live path while decomposition is in flight -While decomposition for an accepted fingerprint is incomplete, the parent planning issue must expose an explicit live path for that same fingerprint. +While decomposition for an accepted fingerprint is incomplete, the source issue must expose an explicit live path for that same fingerprint. -The accepted interaction by itself is only evidence that the plan was approved. It is not a sufficient live path once decomposition begins. The parent issue must make it clear what moves the fingerprint forward next, such as: +The accepted interaction by itself is only evidence that the plan was approved. It is not a sufficient live path once decomposition begins. The source issue must make it clear what moves the fingerprint forward next, such as: - the active decomposition run - a queued continuation wake for the same assignee - a monitor or explicit recovery action tied to the same decomposition claim - a blocked state that names the real blocker for finishing that claimed decomposition -If the live run disappears, Paperclip must repair, resume, or visibly block the existing claim. It must not leave the parent in a state where a second run can interpret the same acceptance as fresh permission to create sibling issues again. +If the live run disappears, Paperclip must repair, resume, or visibly block the existing claim. It must not leave the source issue in a state where a second run can interpret the same acceptance as fresh permission to create sibling issues again. ### Concurrent and repeat attempts diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index 6ceffaec356..11f917854ff 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -514,6 +514,164 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only"); }, 20_000); + it("guards cross-issue accepted-plan retries even when the waking issue is standard work mode", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const issueId = randomUUID(); + const otherPlanningIssueId = randomUUID(); + const agentId = randomUUID(); + const repoRoot = await createGitRepo(); + tempRoots.push(repoRoot); + + await instanceSettingsService(db).updateExperimental({ + enableIsolatedWorkspaces: false, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Accepted Plan Routing", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + cwd: repoRoot, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(issues).values([ + { + id: issueId, + companyId, + projectId, + projectWorkspaceId, + title: "Implementation wake after accepted plan", + status: "in_progress", + workMode: "standard", + priority: "medium", + assigneeAgentId: agentId, + identifier: "PAP-9401", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: otherPlanningIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Earlier accepted plan", + status: "in_progress", + workMode: "planning", + priority: "medium", + assigneeAgentId: agentId, + identifier: "PAP-9402", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await seedAcceptedPlanClaim({ + companyId, + issueId: otherPlanningIssueId, + ownerAgentId: agentId, + status: "in_flight", + }); + await db.insert(agentTaskSessions).values({ + companyId, + agentId, + adapterType: "codex_local", + taskKey: issueId, + sessionParamsJson: { + sessionId: "stale-standard-cross-issue-session", + cwd: repoRoot, + }, + sessionDisplayId: "stale-standard-cross-issue-session", + }); + adapterExecute.mockImplementationOnce(async () => { + await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId)); + return { + exitCode: 0, + signal: null, + timedOut: false, + sessionParams: { sessionId: "fresh-session" }, + sessionDisplayId: "fresh-session", + summary: "Suppressed cross-issue accepted-plan continuation for a standard-work wake.", + provider: "test", + model: "test-model", + }; + }); + + const heartbeat = heartbeatService(db); + const run = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId, + interactionId: "interaction-standard-cross-issue", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + mutation: "interaction", + }, + contextSnapshot: { + issueId, + taskId: issueId, + wakeReason: "issue_commented", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }, + }); + + expect(run).not.toBeNull(); + await vi.waitFor(async () => { + const latest = await heartbeat.getRun(run!.id); + expect(latest?.status).toBe("succeeded"); + }, { timeout: 10_000 }); + + expect(adapterExecute).toHaveBeenCalledTimes(1); + const adapterInput = adapterExecute.mock.calls[0]?.[0] as { + runtime: { sessionId: string | null; sessionParams: Record | null }; + context: Record; + }; + expect(adapterInput.runtime.sessionId).toBeNull(); + expect(adapterInput.runtime.sessionParams).toBeNull(); + expect(adapterInput.context.acceptedPlanWakeRouting).toEqual(expect.objectContaining({ + reason: "other_issue_claim_in_flight", + otherActiveClaimIssueId: otherPlanningIssueId, + otherActiveClaimIdentifier: "PAP-9402", + })); + expect(adapterInput.context.paperclipTaskMarkdown).toContain("Issue: \"PAP-9401\""); + expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only"); + }, 20_000); + it("preserves accepted-plan continuation resume state when the wake issue owns the in-flight claim", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 674ccecb555..1fa9b800693 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -55,6 +55,23 @@ describe("buildPaperclipTaskMarkdown", () => { expect(acceptedConfirmation).not.toContain("Make the plan only."); }); + it("adds accepted-plan continuation guidance for standard-work issues when the wake is flagged as a plan continuation", () => { + const acceptedConfirmation = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-2", + identifier: "PAP-415", + title: "Implement the fix", + workMode: "standard", + description: null, + }, + acceptedPlanContinuation: true, + }); + + expect(acceptedConfirmation).toContain("Accepted plan directive:"); + expect(acceptedConfirmation).toContain("Create child issues from the approved plan only"); + expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\""); + }); + it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => { const commentWake = buildPaperclipTaskMarkdown({ issue: { diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index e4858e33e31..5ee259a2044 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -537,6 +537,14 @@ describe.sequential("issue thread interaction routes", () => { payload: { version: 1, prompt: "Approve this plan?", + target: { + type: "issue_document", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + documentId: "document-plan", + key: "plan", + revisionId: "revision-plan", + revisionNumber: 1, + }, }, result: { version: 1, @@ -572,6 +580,65 @@ describe.sequential("issue thread interaction routes", () => { ); }); + it("forces a fresh workspace-aware session when accepting a plan document confirmation on a standard-work issue", async () => { + mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "standard" })); + mockInteractionService.acceptInteraction.mockResolvedValueOnce({ + interaction: { + id: "interaction-standard-plan", + companyId: "company-1", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + kind: "request_confirmation", + status: "accepted", + continuationPolicy: "wake_assignee_on_accept", + idempotencyKey: "confirmation:issue:plan:revision-standard", + sourceCommentId: null, + sourceRunId: "run-standard-plan", + payload: { + version: 1, + prompt: "Approve this plan?", + target: { + type: "issue_document", + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + documentId: "document-plan", + key: "plan", + revisionId: "revision-standard", + revisionNumber: 2, + }, + }, + result: { + version: 1, + outcome: "accepted", + }, + createdAt: "2026-04-20T12:00:00.000Z", + updatedAt: "2026-04-20T12:05:00.000Z", + resolvedAt: "2026-04-20T12:05:00.000Z", + }, + createdIssues: [], + }); + const app = await createApp(); + + const res = await request(app) + .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-standard-plan/accept") + .send({}); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + reason: "issue_commented", + contextSnapshot: expect.objectContaining({ + issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + interactionId: "interaction-standard-plan", + interactionKind: "request_confirmation", + interactionStatus: "accepted", + forceFreshSession: true, + workspaceRefreshReason: "accepted_plan_confirmation", + }), + }), + ); + }); + it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { mockIssueService.getById.mockResolvedValueOnce(createIssue({ status: "in_review", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ffc4c0408fe..1634893f6aa 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -3318,6 +3318,7 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { assigneeAgentId?: string; sourceIssueId?: string; issueTitle?: string; + workMode?: "planning" | "standard"; }) { const companyId = args?.companyId ?? randomUUID(); const goalId = args?.goalId ?? randomUUID(); @@ -3362,7 +3363,7 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { title: args?.issueTitle ?? "Planning issue", status: "in_progress", priority: "medium", - workMode: "planning", + workMode: args?.workMode ?? "planning", assigneeAgentId: assigneeAgentId, }); await db.insert(documents).values({ @@ -3528,6 +3529,30 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { }); }); + 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 = [ @@ -3795,6 +3820,88 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { 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(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ddccba895cc..b2f2d872377 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -100,6 +100,7 @@ import { assertEnvironmentSelectionForCompany } from "./environment-selection.js import { executionWorkspaceService as executionWorkspaceServiceDirect } from "../services/execution-workspaces.js"; import { feedbackService } from "../services/feedback.js"; import { instanceSettingsService } from "../services/instance-settings.js"; +import { readAcceptedPlanConfirmationTarget } from "../services/issues.js"; import { environmentService } from "../services/environments.js"; import { redactSensitiveText } from "../redaction.js"; import { @@ -5264,10 +5265,12 @@ export function issueRoutes( }); } + const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(interaction.payload); const acceptedPlanConfirmation = interaction.kind === "request_confirmation" && interaction.status === "accepted" && - issue.workMode === "planning"; + acceptedPlanTarget?.issueId === issue.id && + acceptedPlanTarget.key === "plan"; queueResolvedInteractionContinuationWakeup({ heartbeat, issue: continuationWakeIssue, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 100fb0e53e0..9a42e17a2cc 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1947,11 +1947,11 @@ async function resolveAcceptedPlanWakeRoutingDecision(args: { companyId: string; agentId: string; issueId: string | null; - issueWorkMode: string | null | undefined; + acceptedPlanContinuationWake: boolean; contextSnapshot: Record; }): Promise { if (args.issueId === null) return null; - if (args.issueWorkMode !== "planning") return null; + if (!args.acceptedPlanContinuationWake) return null; const activeClaims = await args.db .select({ @@ -2283,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) => { @@ -2297,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 = [ @@ -2324,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) { @@ -7115,7 +7125,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) companyId: agent.companyId, agentId: agent.id, issueId, - issueWorkMode: issueContext.workMode, + acceptedPlanContinuationWake: + readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" + || ( + issueContext.workMode === "planning" + && readNonEmptyString(context.interactionKind) === "request_confirmation" + && readNonEmptyString(context.interactionStatus) === "accepted" + ), contextSnapshot: context, }) : null; @@ -7129,6 +7145,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; if (acceptedPlanWakeRoutingDecision.suppressAcceptedContinuation) { clearInteractionContinuationWakeContext(context); + delete context.workspaceRefreshReason; } } else { delete context.acceptedPlanWakeRouting; @@ -7232,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 = { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ac573a4ba6d..71b9e0e1a78 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -301,6 +301,8 @@ function serializeAcceptedPlanDecomposition( 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, @@ -482,7 +484,7 @@ function normalizeIssuePlanDecompositionChildIds(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string" && item.length > 0); } -function readAcceptedPlanConfirmationTarget(payload: unknown): { +export function readAcceptedPlanConfirmationTarget(payload: unknown): { revisionId: string; key: string; issueId: string; @@ -499,6 +501,46 @@ function readAcceptedPlanConfirmationTarget(payload: unknown): { return { revisionId, key, issueId }; } +async function resolveAcceptedPlanClaimOwner(input: { + dbOrTx: Pick; + claim: Pick; + 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, input: { @@ -4226,15 +4268,11 @@ export function issueService(db: Db) { 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, @@ -4342,15 +4380,20 @@ export function issueService(db: Db) { } 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, - ownerAgentId: data.actorAgentId ?? null, - ownerUserId: data.actorUserId ?? null, - ownerRunId: data.actorRunId ?? null, + ...ownerPatch, updatedAt: completedAt, }) .where(eq(issuePlanDecompositions.id, claim.id)) @@ -4371,15 +4414,20 @@ export function issueService(db: Db) { 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, - ownerAgentId: data.actorAgentId ?? null, - ownerUserId: data.actorUserId ?? null, - ownerRunId: data.actorRunId ?? null, + ...ownerPatch, updatedAt: now, }) .where(eq(issuePlanDecompositions.id, claim.id))