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:
Devin FoleyandPaperclip committed 2026-05-27 20:15:35 -07:00
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);
+1 -1
View File
@@ -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[],