mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +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
|
|
@ -7,15 +7,26 @@ import { promisify } from "node:util";
|
|||
import { eq, ne } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issuePlanDecompositions,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -97,6 +108,25 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
|
|||
const root = tempRoots.pop();
|
||||
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(agentTaskSessions);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -104,6 +134,57 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
|
|||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedAcceptedPlanClaim(args: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
ownerAgentId: string;
|
||||
status?: "in_flight" | "completed";
|
||||
}) {
|
||||
const documentId = randomUUID();
|
||||
const revisionId = randomUUID();
|
||||
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId: args.companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "Plan body",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: args.ownerAgentId,
|
||||
updatedByAgentId: args.ownerAgentId,
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId: args.companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "Plan body",
|
||||
createdByAgentId: args.ownerAgentId,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId: args.companyId,
|
||||
issueId: args.issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(issuePlanDecompositions).values({
|
||||
companyId: args.companyId,
|
||||
sourceIssueId: args.issueId,
|
||||
acceptedPlanRevisionId: revisionId,
|
||||
status: args.status ?? "in_flight",
|
||||
requestFingerprint: `claim:${args.issueId}`,
|
||||
requestedChildCount: 1,
|
||||
requestedChildren: [{ title: "child-1" }],
|
||||
childIssueIds: [],
|
||||
ownerAgentId: args.ownerAgentId,
|
||||
updatedAt: new Date(),
|
||||
...(args.status === "completed" ? { completedAt: new Date() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
|
@ -276,4 +357,451 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => {
|
|||
});
|
||||
expect(isolatedRows[0]?.cwd).not.toBe(repoRoot);
|
||||
}, 20_000);
|
||||
|
||||
it("forces a fresh session and suppresses accepted-plan continuation when another issue owns the in-flight claim", 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: "Later planning wake",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
identifier: "PAP-9301",
|
||||
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-9302",
|
||||
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-cross-issue-session",
|
||||
cwd: repoRoot,
|
||||
},
|
||||
sessionDisplayId: "stale-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.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: {
|
||||
issueId,
|
||||
interactionId: "interaction-cross-issue",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
mutation: "interaction",
|
||||
},
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_blockers_resolved",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
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<string, unknown> | null };
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
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-9302",
|
||||
}));
|
||||
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Make the plan only.");
|
||||
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<string, unknown> | null };
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
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();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const issueId = 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 Retry",
|
||||
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: "Accepted plan retry",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
identifier: "PAP-9303",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await seedAcceptedPlanClaim({
|
||||
companyId,
|
||||
issueId,
|
||||
ownerAgentId: agentId,
|
||||
status: "in_flight",
|
||||
});
|
||||
await db.insert(agentTaskSessions).values({
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "codex_local",
|
||||
taskKey: issueId,
|
||||
sessionParamsJson: {
|
||||
sessionId: "accepted-plan-retry-session",
|
||||
cwd: repoRoot,
|
||||
},
|
||||
sessionDisplayId: "accepted-plan-retry-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: "accepted-plan-retry-session" },
|
||||
sessionDisplayId: "accepted-plan-retry-session",
|
||||
summary: "Resumed accepted-plan continuation for the same issue.",
|
||||
provider: "test",
|
||||
model: "test-model",
|
||||
};
|
||||
});
|
||||
|
||||
const heartbeat = heartbeatService(db);
|
||||
const run = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
payload: {
|
||||
issueId,
|
||||
interactionId: "interaction-same-issue",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
mutation: "interaction",
|
||||
},
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
wakeReason: "issue_blockers_resolved",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
},
|
||||
});
|
||||
|
||||
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<string, unknown> | null };
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
|
||||
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
|
||||
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only");
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import {
|
|||
companySkills,
|
||||
companies,
|
||||
costEvents,
|
||||
documentAnnotationAnchorSnapshots,
|
||||
documentAnnotationComments,
|
||||
documentAnnotationThreads,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
|
|
@ -20,8 +23,10 @@ import {
|
|||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issuePlanDecompositions,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issueTreeHoldMembers,
|
||||
issueTreeHolds,
|
||||
issueWorkProducts,
|
||||
|
|
@ -323,6 +328,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
await db.delete(costEvents);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(environments);
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(documentAnnotationComments);
|
||||
await db.delete(documentAnnotationAnchorSnapshots);
|
||||
await db.delete(documentAnnotationThreads);
|
||||
await db.delete(issueWorkProducts);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueDocuments);
|
||||
|
|
@ -368,6 +378,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(issuePlanDecompositions);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(documentAnnotationComments);
|
||||
await db.delete(documentAnnotationAnchorSnapshots);
|
||||
await db.delete(documentAnnotationThreads);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
try {
|
||||
await db.delete(companies);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ describe("instance settings routes", () => {
|
|||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
@ -82,6 +83,7 @@ describe("instance settings routes", () => {
|
|||
experimental: {
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
@ -125,6 +127,7 @@ describe("instance settings routes", () => {
|
|||
expect(getRes.body).toEqual({
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableCloudSync: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ describe("instance settings service", () => {
|
|||
expect(normalizeExperimentalSettings({
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
@ -14,6 +15,7 @@ describe("instance settings service", () => {
|
|||
})).toEqual({
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableCloudSync: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
createIssueThreadInteractionSchema,
|
||||
createIssueWorkProductSchema,
|
||||
createIssueLabelSchema,
|
||||
createAcceptedPlanDecompositionSchema,
|
||||
checkoutIssueSchema,
|
||||
createDocumentAnnotationCommentSchema,
|
||||
createDocumentAnnotationThreadSchema,
|
||||
|
|
@ -99,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 {
|
||||
|
|
@ -3692,6 +3694,151 @@ export function issueRoutes(
|
|||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/accepted-plan-decompositions", 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);
|
||||
const decompositions = await svc.listAcceptedPlanDecompositions(sourceIssue.id);
|
||||
res.json(decompositions);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -5118,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,
|
||||
|
|
|
|||
|
|
@ -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