mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(interactions): gate issue.interaction.accept on workspace_finalize (PAPA-440)
Follow-up from PAPA-432. The wake-gating barrier for dependent issues
landed in commit 1acf2f107, but acceptInteraction itself could still
proceed while the issue's most recent run had not finished syncing its
remote-side commits back into the local execution-workspace cwd. A board
user accepting a request_confirmation in that window approves work that
the local worktree does not yet reflect.
This change adds a precondition to acceptInteraction:
- Look up the issue's executionWorkspaceId. If unset, no gate applies
(backwards-compatible for issues with no execution workspace attached).
- Otherwise, reuse listUnfinalizedExecutionWorkspaceIds to check the
latest workspace operation. If it is not workspace_finalize=succeeded
(no finalize row at all, or finalize=failed as latest), refuse the
accept with a 409 conflict referencing the workspace id. Callers can
retry once finalize lands.
The PAPA-432 wake-gating barrier remains the sole signal dependents
look at — this gate operates earlier, on the accept itself, so the
issue can't be flipped forward before the worktree state matches.
Server-side test coverage:
- No finalize row at all (worktree_prepare only) → 409
- finalize=failed as latest row → 409
- finalize=succeeded as latest row (with a prior failed row) → accepted
- No executionWorkspaceId attached → accepted
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1195160377
commit
62af95e9ab
3 files changed
+246
-2
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -48,7 +52,11 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
@@ -1135,4 +1143,213 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
async function seedAcceptGateFixture() {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const goalId = 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(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "exec",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Accept gate fixture",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
projectId,
|
||||
goalId,
|
||||
title: "Issue with execution workspace",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
|
||||
}
|
||||
|
||||
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
// A run touched the workspace (prepare) but never recorded workspace_finalize.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows accept when the issue has no execution workspace attached", async () => {
|
||||
const { companyId, issueId } = await seedConfirmationIssue("No execution workspace accept");
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId: null, projectId: null },
|
||||
created.id,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: created.id,
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
suggestTasksResultSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||
|
||||
type InteractionActor = {
|
||||
agentId?: string | null;
|
||||
@@ -457,6 +457,32 @@ export function issueThreadInteractionService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
||||
db: Pick<Db, "select">;
|
||||
issue: { id: string; companyId: string };
|
||||
}) {
|
||||
const executionWorkspaceId = await args.db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, args.issue.id))
|
||||
.then((rows: Array<{ executionWorkspaceId: string | null }>) => rows[0]?.executionWorkspaceId ?? null);
|
||||
|
||||
if (!executionWorkspaceId) return;
|
||||
|
||||
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
|
||||
args.db,
|
||||
args.issue.companyId,
|
||||
[executionWorkspaceId],
|
||||
);
|
||||
if (!unfinalized.has(executionWorkspaceId)) return;
|
||||
|
||||
throw conflict(
|
||||
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
|
||||
+ "Retry once the local worktree has finished syncing.",
|
||||
{ executionWorkspaceId },
|
||||
);
|
||||
}
|
||||
|
||||
async function getPendingInteractionForResolution(args: {
|
||||
issue: { id: string; companyId: string };
|
||||
interactionId: string;
|
||||
@@ -745,6 +771,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
): Promise<ResolvedInteractionResult> => {
|
||||
const data = acceptIssueThreadInteractionSchema.parse(input);
|
||||
const current = await getPendingInteractionForResolution({ issue, interactionId });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
switch (current.kind) {
|
||||
case "suggest_tasks":
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
|
||||
@@ -400,7 +400,7 @@ function createIssueDependencyReadiness(issueId: string): IssueDependencyReadine
|
||||
* Workspaces with no recorded operations are considered finalized (nothing has
|
||||
* touched them since they were realized).
|
||||
*/
|
||||
async function listUnfinalizedExecutionWorkspaceIds(
|
||||
export async function listUnfinalizedExecutionWorkspaceIds(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
executionWorkspaceIds: string[],
|
||||
|
||||
Reference in new issue
Block a user