diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 845eb42ffba..ba8e529f6ff 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -1345,13 +1345,23 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); describe("workspace_finalize accept gate", () => { - async function seedAcceptGateFixture() { + type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation"; + + async function seedAcceptGateFixture(options?: { + kind?: AcceptGateInteractionKind; + sourceRunId?: string | null; + }) { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); const issueId = randomUUID(); const goalId = randomUUID(); + const agentId = randomUUID(); + const sourceRunId = + options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID(); + const foreignRunId = randomUUID(); + const kind = options?.kind ?? "request_confirmation"; await db.insert(companies).values({ id: companyId, @@ -1375,6 +1385,40 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { visibility: "default", isPrimary: true, }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values([ + ...(sourceRunId + ? [ + { + id: sourceRunId, + companyId, + agentId, + invocationSource: "manual", + status: "succeeded", + startedAt: new Date("2026-05-23T21:55:00.000Z"), + finishedAt: new Date("2026-05-23T22:05:00.000Z"), + }, + ] + : []), + { + id: foreignRunId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + startedAt: new Date("2026-05-23T22:10:00.000Z"), + }, + ]); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId, @@ -1404,107 +1448,63 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { executionWorkspaceId, }); + const payload = kind === "request_checkbox_confirmation" + ? { + version: 1 as const, + prompt: "Which files should be accepted?", + options: [ + { id: "file-a", label: "a.txt" }, + { id: "file-b", label: "b.txt" }, + ], + minSelected: 0, + maxSelected: 2, + } + : { + version: 1 as const, + prompt: "Mark this issue done?", + }; + const created = await interactionsSvc.create({ id: issueId, companyId, }, { - kind: "request_confirmation", + kind, continuationPolicy: "wake_assignee", - payload: { - version: 1, - prompt: "Mark this issue done?", - }, + sourceRunId, + payload, }, { userId: "local-board", }); - return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id }; + return { + companyId, + projectId, + executionWorkspaceId, + issueId, + goalId, + interactionId: created.id, + sourceRunId, + foreignRunId, + }; } - 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(); + it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => { + const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } = + await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, - phase: "worktree_prepare", + heartbeatRunId: sourceRunId, + phase: "workspace_finalize", 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", + heartbeatRunId: foreignRunId, + phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), }); @@ -1518,6 +1518,208 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(accepted.interaction).toMatchObject({ id: interactionId, + kind: "request_confirmation", + status: "accepted", + }); + }); + + it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => { + const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = + await seedAcceptGateFixture(); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: sourceRunId, + 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, + message: expect.stringContaining( + "the run that created this interaction has not finished syncing its workspace", + ), + details: { executionWorkspaceId, sourceRunId }, + }); + + const row = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, interactionId)) + .then((rows) => rows[0]); + expect(row?.status).toBe("pending"); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: sourceRunId, + phase: "workspace_finalize", + status: "succeeded", + startedAt: new Date("2026-05-23T22:05:00.000Z"), + }); + + const accepted = await interactionsSvc.acceptInteraction( + { id: issueId, companyId, goalId, projectId: null }, + interactionId, + {}, + { userId: "local-board" }, + ); + + expect(accepted.interaction).toMatchObject({ + id: interactionId, + kind: "request_confirmation", + status: "accepted", + }); + }); + + it("allows request_confirmation accept when sourceRunId is null", async () => { + const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } = + await seedAcceptGateFixture({ sourceRunId: null }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: foreignRunId, + phase: "worktree_prepare", + 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, + kind: "request_confirmation", + status: "accepted", + }); + }); + + it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => { + const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } = + await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: sourceRunId, + phase: "workspace_finalize", + status: "succeeded", + startedAt: new Date("2026-05-23T22:00:00.000Z"), + }); + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: foreignRunId, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:10:00.000Z"), + }); + + const accepted = await interactionsSvc.acceptInteraction( + { id: issueId, companyId, goalId, projectId: null }, + interactionId, + { selectedOptionIds: ["file-b"] }, + { userId: "local-board" }, + ); + + expect(accepted.interaction).toMatchObject({ + id: interactionId, + kind: "request_checkbox_confirmation", + status: "accepted", + result: { + selectedOptionIds: ["file-b"], + }, + }); + }); + + it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => { + const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } = + await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: sourceRunId, + 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, + { selectedOptionIds: ["file-a"] }, + { userId: "local-board" }, + ), + ).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining( + "the run that created this interaction has not finished syncing its workspace", + ), + details: { executionWorkspaceId, sourceRunId }, + }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: sourceRunId, + 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, + { selectedOptionIds: ["file-a"] }, + { userId: "local-board" }, + ); + + expect(accepted.interaction).toMatchObject({ + id: interactionId, + kind: "request_checkbox_confirmation", + status: "accepted", + }); + }); + + it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => { + const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } = + await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null }); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + heartbeatRunId: foreignRunId, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:10:00.000Z"), + }); + + const accepted = await interactionsSvc.acceptInteraction( + { id: issueId, companyId, goalId, projectId: null }, + interactionId, + { selectedOptionIds: ["file-a"] }, + { userId: "local-board" }, + ); + + expect(accepted.interaction).toMatchObject({ + id: interactionId, + kind: "request_checkbox_confirmation", status: "accepted", }); }); @@ -1528,11 +1730,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { // workspace_finalize gate (PAPA-440) must not apply here. Without this // carve-out the board cannot triage suggested tasks on an issue whose // latest workspace op is still worktree_prepare. - const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture(); + const { companyId, executionWorkspaceId, issueId, goalId, foreignRunId } = await seedAcceptGateFixture(); await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + heartbeatRunId: foreignRunId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-28T22:00:00.000Z"), diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 68aecc8b32f..1074c6869e2 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -2924,6 +2924,102 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await tempDb?.cleanup(); }); + async function seedSharedWorkspaceDependency() { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const blockerId = randomUUID(); + const dependentId = randomUUID(); + const foreignIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "QA", + role: "qa", + status: "active", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Shared workspace project", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Shared 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: "Shared exec workspace", + status: "active", + providerType: "git_worktree", + }); + await db.insert(issues).values([ + { + id: blockerId, + companyId, + projectId, + title: "Predecessor", + status: "done", + priority: "medium", + executionWorkspaceId, + }, + { + id: dependentId, + companyId, + projectId, + title: "Dependent", + status: "blocked", + priority: "medium", + assigneeAgentId, + }, + { + id: foreignIssueId, + companyId, + projectId, + title: "Foreign in-flight issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + }, + ]); + await svc.update(dependentId, { blockedByIssueIds: [blockerId] }); + + return { + companyId, + assigneeAgentId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + blockerId, + dependentId, + foreignIssueId, + }; + } + it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => { const companyId = randomUUID(); await db.insert(companies).values({ @@ -3081,86 +3177,82 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", ]); }); - it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => { - const companyId = randomUUID(); - const assigneeAgentId = randomUUID(); - const projectId = randomUUID(); - const projectWorkspaceId = randomUUID(); - const executionWorkspaceId = randomUUID(); + it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => { + const { + companyId, + executionWorkspaceId, + blockerId, + dependentId, + foreignIssueId, + } = await seedSharedWorkspaceDependency(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, + await db.insert(workspaceOperations).values({ companyId, - name: "QA", - role: "qa", - status: "active", - adapterType: "claude_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Shared workspace project", - status: "in_progress", - }); - await db.insert(projectWorkspaces).values({ - id: projectWorkspaceId, - companyId, - projectId, - name: "Shared 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: "Shared exec workspace", - status: "active", - providerType: "git_worktree", + executionWorkspaceId, + issueId: foreignIssueId, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:00:00.000Z"), }); - const blockerId = randomUUID(); - const dependentId = randomUUID(); - await db.insert(issues).values([ - { - id: blockerId, - companyId, - projectId, - title: "Predecessor", - status: "done", - priority: "medium", - executionWorkspaceId, - }, - { + await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([ + expect.objectContaining({ id: dependentId, - companyId, - projectId, - title: "Dependent", - status: "blocked", - priority: "medium", - assigneeAgentId, - }, + blockerIssueIds: [blockerId], + }), ]); - await svc.update(dependentId, { blockedByIssueIds: [blockerId] }); + await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({ + isDependencyReady: true, + pendingFinalizeBlockerIssueIds: [], + unresolvedBlockerIssueIds: [], + }); + }); - // A run touched the workspace (prepare phase) but has not yet recorded + it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => { + const { + companyId, + executionWorkspaceId, + blockerId, + dependentId, + } = await seedSharedWorkspaceDependency(); + + await db.insert(workspaceOperations).values({ + companyId, + executionWorkspaceId, + issueId: null, + phase: "worktree_prepare", + status: "succeeded", + startedAt: new Date("2026-05-23T22:00:00.000Z"), + }); + + await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([ + expect.objectContaining({ + id: dependentId, + blockerIssueIds: [blockerId], + }), + ]); + await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({ + isDependencyReady: true, + pendingFinalizeBlockerIssueIds: [], + unresolvedBlockerIssueIds: [], + }); + }); + + it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => { + const { + companyId, + executionWorkspaceId, + blockerId, + dependentId, + assigneeAgentId, + } = await seedSharedWorkspaceDependency(); + + // The blocker touched its workspace but has not yet recorded // workspace_finalize — the dependent must NOT wake. await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + issueId: blockerId, phase: "worktree_prepare", status: "succeeded", startedAt: new Date("2026-05-23T22:00:00.000Z"), @@ -3177,6 +3269,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + issueId: blockerId, phase: "workspace_finalize", status: "failed", startedAt: new Date("2026-05-23T22:05:00.000Z"), @@ -3188,6 +3281,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", await db.insert(workspaceOperations).values({ companyId, executionWorkspaceId, + issueId: blockerId, phase: "workspace_finalize", status: "succeeded", startedAt: new Date("2026-05-23T22:10:00.000Z"), diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d066bc86be0..ea16d1b37a3 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3439,7 +3439,8 @@ export function agentRoutes( const agentId = req.query.agentId as string | undefined; const limitParam = req.query.limit as string | undefined; const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined; - const runs = await heartbeat.list(companyId, agentId, limit); + const summary = req.query.summary === "true" || req.query.summary === "1"; + const runs = await heartbeat.list(companyId, agentId, limit, { summary }); res.json(runs); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8e40055e9a4..2c2e6b32d6d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3906,6 +3906,7 @@ export function issueRoutes( entityType: "issue", entityId: issue.id, details: { + key: thread.documentKey, documentKey: thread.documentKey, documentId: thread.documentId, threadId: thread.id, @@ -3990,6 +3991,7 @@ export function issueRoutes( entityType: "issue", entityId: issue.id, details: { + key: keyParsed.data, documentKey: keyParsed.data, threadId: comment.threadId, commentId: comment.id, @@ -4043,6 +4045,7 @@ export function issueRoutes( entityType: "issue", entityId: issue.id, details: { + key: thread.documentKey, documentKey: thread.documentKey, documentId: thread.documentId, threadId: thread.id, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index c75071eb909..fd81e5a80f6 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -39,7 +39,7 @@ import { suggestTasksResultSchema, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; -import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js"; +import { issueService, runWorkspaceIsFinalized } from "./issues.js"; type InteractionActor = { agentId?: string | null; @@ -541,7 +541,10 @@ export function issueThreadInteractionService(db: Db) { async function assertIssueWorkspaceFinalizedForAccept(args: { db: Pick; issue: { id: string; companyId: string }; + sourceRunId: string | null; }) { + if (!args.sourceRunId) return; + const executionWorkspaceId = await args.db .select({ executionWorkspaceId: issues.executionWorkspaceId }) .from(issues) @@ -550,17 +553,18 @@ export function issueThreadInteractionService(db: Db) { if (!executionWorkspaceId) return; - const unfinalized = await listUnfinalizedExecutionWorkspaceIds( + const isFinalized = await runWorkspaceIsFinalized( args.db, args.issue.companyId, - [executionWorkspaceId], + executionWorkspaceId, + args.sourceRunId, ); - if (!unfinalized.has(executionWorkspaceId)) return; + if (isFinalized) return; throw conflict( - "Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. " + "Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. " + "Retry once the local worktree has finished syncing.", - { executionWorkspaceId }, + { executionWorkspaceId, sourceRunId: args.sourceRunId }, ); } @@ -870,7 +874,7 @@ export function issueThreadInteractionService(db: Db) { // workspace_finalize gate (PAPA-440) does not apply here. return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor); case "request_confirmation": { - await assertIssueWorkspaceFinalizedForAccept({ db, issue }); + await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId }); const accepted = await acceptRequestConfirmation({ issue, current, @@ -884,7 +888,7 @@ export function issueThreadInteractionService(db: Db) { }; } case "request_checkbox_confirmation": { - await assertIssueWorkspaceFinalizedForAccept({ db, issue }); + await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId }); const accepted = await acceptRequestConfirmation({ issue, current, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 7060cec909b..3b51e6900e3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -689,6 +689,94 @@ export async function listUnfinalizedExecutionWorkspaceIds( return unfinalized; } +async function listPendingFinalizeBlockerIssueIds( + dbOrTx: Pick, + companyId: string, + blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>, +): Promise> { + const pending = new Set(); + const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))]; + const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))]; + if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending; + + const rows = await dbOrTx + .select({ + issueId: workspaceOperations.issueId, + executionWorkspaceId: workspaceOperations.executionWorkspaceId, + phase: workspaceOperations.phase, + status: workspaceOperations.status, + startedAt: workspaceOperations.startedAt, + }) + .from(workspaceOperations) + .where( + and( + eq(workspaceOperations.companyId, companyId), + inArray(workspaceOperations.issueId, blockerIssueIds), + inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds), + ), + ); + + const latestByBlockerWorkspace = new Map(); + for (const row of rows) { + if (!row.issueId || !row.executionWorkspaceId) continue; + const key = `${row.issueId}:${row.executionWorkspaceId}`; + const current = latestByBlockerWorkspace.get(key); + if (!current || row.startedAt > current.startedAt) { + latestByBlockerWorkspace.set(key, { + phase: row.phase, + status: row.status, + startedAt: row.startedAt, + }); + } + } + + for (const pair of blockerWorkspacePairs) { + const latest = latestByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`); + if (!latest) continue; // no attributed ops recorded -> nothing to finalize for this blocker + if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue; + pending.add(pair.blockerIssueId); + } + + return pending; +} + +/** + * Returns whether a specific run's operations on a specific execution workspace + * reached the workspace_finalize barrier. + * + * Runs with no operations on the workspace are considered finalized because + * they never touched the workspace state that accept/review gates protect. + */ +export async function runWorkspaceIsFinalized( + dbOrTx: Pick, + companyId: string, + executionWorkspaceId: string, + runId: string, +): Promise { + const rows = await dbOrTx + .select({ + phase: workspaceOperations.phase, + status: workspaceOperations.status, + startedAt: workspaceOperations.startedAt, + }) + .from(workspaceOperations) + .where( + and( + eq(workspaceOperations.companyId, companyId), + eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId), + eq(workspaceOperations.heartbeatRunId, runId), + ), + ); + + let latest: { phase: string; status: string; startedAt: Date } | null = null; + for (const row of rows) { + if (!latest || row.startedAt > latest.startedAt) latest = row; + } + + if (!latest) return true; + return latest.phase === "workspace_finalize" && latest.status === "succeeded"; +} + async function listIssueDependencyReadinessMap( dbOrTx: Pick, companyId: string, @@ -718,19 +806,22 @@ async function listIssueDependencyReadinessMap( ), ); - // Collect executionWorkspaceIds of "done" blockers — these are the only ones + // Collect issue/workspace pairs of "done" blockers — these are the only ones // subject to the workspace-finalize barrier. Blockers that aren't done already // mark the dependent as not-ready and don't need a finalize check. - const doneBlockerWorkspaceIds = new Set(); + const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = []; for (const row of blockerRows) { if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) { - doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId); + doneBlockerWorkspacePairs.push({ + blockerIssueId: row.blockerIssueId, + executionWorkspaceId: row.blockerExecutionWorkspaceId, + }); } } - const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds( + const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds( dbOrTx, companyId, - [...doneBlockerWorkspaceIds], + doneBlockerWorkspacePairs, ); for (const row of blockerRows) { @@ -745,7 +836,7 @@ async function listIssueDependencyReadinessMap( current.isDependencyReady = false; } else if ( row.blockerExecutionWorkspaceId && - unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId) + pendingFinalizeBlockerIssueIds.has(row.blockerIssueId) ) { // Workspace-finalize barrier: the blocker's most recent run on its // execution workspace hasn't recorded a successful workspace_finalize. diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 49a18356790..914f45645f3 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -12,6 +12,7 @@ import { VIRTUALIZED_THREAD_ROW_THRESHOLD, canStopIssueChatRun, findLatestCommentMessageIndex, + getVirtualizedMeasurementScrollAdjustment, resolveAssistantMessageFoldedState, resolveIssueChatHumanAuthor, } from "./IssueChatThread"; @@ -674,6 +675,51 @@ describe("IssueChatThread", () => { }); }); + it("cycles work modes and prevents default when iOS leaves the keydown code empty", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null; + expect(composer).not.toBeNull(); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); + + // iOS Safari with a hardware keyboard frequently reports an empty `code` for + // cmd-period. Without a `key` fallback the handler returns early, never calls + // preventDefault, and Safari's default cancel/dismiss closes the view. + const evt = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "", + key: ".", + metaKey: true, + }); + act(() => { + composer?.dispatchEvent(evt); + }); + + expect(evt.defaultPrevented).toBe(true); + expect(composer?.getAttribute("data-pending-work-mode")).not.toBe("standard"); + + act(() => { + root.unmount(); + }); + }); + it("virtualizes long merged threads so only a windowed slice mounts", () => { const root = createRoot(container); const totalMergedRows = @@ -1343,6 +1389,31 @@ describe("IssueChatThread", () => { }); }); + it("keeps the viewport anchored when virtualized rows above it remeasure", () => { + expect(getVirtualizedMeasurementScrollAdjustment({ + itemStart: 200, + previousSize: 220, + nextSize: 360, + viewportStart: 480, + })).toBe(140); + + expect(getVirtualizedMeasurementScrollAdjustment({ + itemStart: 200, + previousSize: 360, + nextSize: 180, + viewportStart: 620, + })).toBe(-180); + }); + + it("does not scroll-anchor virtualized measurement changes inside the viewport", () => { + expect(getVirtualizedMeasurementScrollAdjustment({ + itemStart: 420, + previousSize: 220, + nextSize: 360, + viewportStart: 480, + })).toBe(0); + }); + it("renders virtualized rows with the same role/kind metadata as the direct path", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 48b4d92244d..ccccf325a28 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -51,6 +51,7 @@ import { copyTextToClipboard } from "../lib/clipboard"; import { buildIssueChatMessages, formatDurationWords, + isCoTSegmentActive, stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, @@ -870,13 +871,16 @@ function IssueChatChainOfThought({ (p): p is ToolCallMessagePart => p.type === "tool-call", ); - const isActive = isMessageRunning; - const [expanded, setExpanded] = useState(isActive); - const rawSegments = Array.isArray(custom.chainOfThoughtSegments) ? (custom.chainOfThoughtSegments as SegmentTiming[]) : []; const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; + const isActive = isCoTSegmentActive({ + isMessageRunning, + segmentIndex: myIndex, + segmentCount: rawSegments.length, + }); + const [expanded, setExpanded] = useState(isActive); const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive); useEffect(() => { @@ -2941,6 +2945,18 @@ type SimpleVirtualItem = { size: number; }; +export function getVirtualizedMeasurementScrollAdjustment(args: { + itemStart: number; + previousSize: number; + nextSize: number; + viewportStart: number; +}) { + const { itemStart, previousSize, nextSize, viewportStart } = args; + const previousEnd = itemStart + previousSize; + if (previousEnd > viewportStart) return 0; + return nextSize - previousSize; +} + function useIssueThreadVirtualizer({ count, estimateSize, @@ -3055,7 +3071,20 @@ function useIssueThreadVirtualizer({ const key = getItemKey(index); const previousSize = measuredSizeByKeyRef.current.get(key) ?? estimatedSize; if (Math.abs(previousSize - measuredSize) < 1) return; + const scrollAdjustment = getVirtualizedMeasurementScrollAdjustment({ + itemStart: itemStarts[index] ?? scrollMargin, + previousSize, + nextSize: measuredSize, + viewportStart: Math.max(scrollMargin, scrollOffset()), + }); measuredSizeByKeyRef.current.set(key, measuredSize); + if (Math.abs(scrollAdjustment) >= 1) { + if (mode.kind === "window") { + window.scrollBy({ top: scrollAdjustment, behavior: "auto" }); + } else { + mode.element.scrollBy({ top: scrollAdjustment, behavior: "auto" }); + } + } rerender((value) => value + 1); }, }; @@ -3746,7 +3775,13 @@ const IssueChatComposer = forwardRef) { - if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return; + // Match the period via both `code` and `key`: iOS Safari with a hardware + // keyboard often leaves `code` empty for cmd-period, so relying on it alone + // lets the event fall through and triggers Safari's default cancel/dismiss + // (which closes the view). Catching `key === "."` keeps the shortcut working + // on iOS while preserving desktop behavior. + const isPeriod = evt.code === "Period" || evt.key === "."; + if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return; evt.preventDefault(); setPendingWorkMode((current) => nextWorkMode(current, true)); } diff --git a/ui/src/components/IssueChatThreadClassic.tsx b/ui/src/components/IssueChatThreadClassic.tsx index 0da6f11cf35..f7188e259ce 100644 --- a/ui/src/components/IssueChatThreadClassic.tsx +++ b/ui/src/components/IssueChatThreadClassic.tsx @@ -61,6 +61,7 @@ import { copyTextToClipboard } from "../lib/clipboard"; import { buildIssueChatMessages, formatDurationWords, + isCoTSegmentActive, stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, @@ -849,13 +850,16 @@ function IssueChatChainOfThought({ (p): p is ToolCallMessagePart => p.type === "tool-call", ); - const isActive = isMessageRunning; - const [expanded, setExpanded] = useState(isActive); - const rawSegments = Array.isArray(custom.chainOfThoughtSegments) ? (custom.chainOfThoughtSegments as SegmentTiming[]) : []; const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; + const isActive = isCoTSegmentActive({ + isMessageRunning, + segmentIndex: myIndex, + segmentCount: rawSegments.length, + }); + const [expanded, setExpanded] = useState(isActive); const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive); useEffect(() => { @@ -3587,7 +3591,13 @@ const IssueChatComposer = forwardRef) { - if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return; + // Match the period via both `code` and `key`: iOS Safari with a hardware + // keyboard often leaves `code` empty for cmd-period, so relying on it alone + // lets the event fall through and triggers Safari's default cancel/dismiss + // (which closes the view). Catching `key === "."` keeps the shortcut working + // on iOS while preserving desktop behavior. + const isPeriod = evt.code === "Period" || evt.key === "."; + if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return; evt.preventDefault(); setPendingWorkMode((current) => nextWorkMode(current, false)); } diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 673b7032b3e..f696b0b93d5 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -139,6 +139,9 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidations).toContainEqual({ queryKey: queryKeys.issues.documentRevisions("issue-1", "plan"), }); + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document-annotations", "issue-1", "plan"], + }); expect(invalidations).toContainEqual({ queryKey: queryKeys.issues.documents("PAP-9403"), }); @@ -148,6 +151,9 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidations).toContainEqual({ queryKey: queryKeys.issues.documentRevisions("PAP-9403", "plan"), }); + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document-annotations", "PAP-9403", "plan"], + }); expect(invalidations).not.toContainEqual({ queryKey: queryKeys.issues.documents("issue-1"), refetchType: "inactive", @@ -186,6 +192,83 @@ describe("LiveUpdatesProvider issue invalidation", () => { expect(invalidations).toContainEqual({ queryKey: ["issues", "document-revisions", "issue-1"], }); + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document-annotations", "issue-1"], + }); + }); + + it("refreshes document annotation caches when annotation activity arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "issue", + entityId: "issue-1", + action: "issue.document_annotation_comment_added", + actorType: "user", + actorId: "user-2", + details: { + identifier: "PAP-9403", + documentKey: "plan", + threadId: "thread-1", + commentId: "comment-1", + }, + }, + { userId: "user-1", agentId: null }, + ); + + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document-annotations", "issue-1", "plan"], + }); + expect(invalidations).toContainEqual({ + queryKey: ["issues", "document-annotations", "PAP-9403", "plan"], + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.issues.documents("issue-1"), + }); + }); + + it("refreshes routine description annotation caches when routine annotation activity arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "routine", + entityId: "routine-1", + action: "routine.document_annotation_comment_added", + actorType: "user", + actorId: "user-2", + details: { + documentKey: "description", + threadId: "thread-1", + commentId: "comment-1", + }, + }, + { userId: "user-1", agentId: null }, + ); + + expect(invalidations).toContainEqual({ + queryKey: ["routines"], + }); + expect(invalidations).toContainEqual({ + queryKey: ["routines", "document-annotations", "routine-1", "description"], + }); }); it("keeps self-authored comment events from refetching the active issue tree", () => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 67b5b3503a1..1e1b908360b 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -415,6 +415,20 @@ const ISSUE_DOCUMENT_ACTIVITY_ACTIONS = new Set([ "issue.document_restored", "issue.document_deleted", ]); +const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ + "issue.document_annotation_thread_created", + "issue.document_annotation_comment_added", + "issue.document_annotation_thread_resolved", + "issue.document_annotation_thread_reopened", + "issue.document_annotation_remapped", +]); +const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ + "routine.document_annotation_thread_created", + "routine.document_annotation_comment_added", + "routine.document_annotation_thread_resolved", + "routine.document_annotation_thread_reopened", + "routine.document_annotation_remapped", +]); const AGENT_TOAST_STATUSES = new Set(["error"]); const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); @@ -710,6 +724,18 @@ function invalidateActivityQueries( queryClient.invalidateQueries({ queryKey: ["issues", "document-revisions", ref], ...invalidationOptions }); } } + if ( + action && + (ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action) || ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action)) + ) { + const documentKey = readString(details?.key) ?? readString(details?.documentKey); + queryClient.invalidateQueries({ + queryKey: documentKey + ? ["issues", "document-annotations", ref, documentKey] + : ["issues", "document-annotations", ref], + ...invalidationOptions, + }); + } if (action?.startsWith("issue.thread_interaction_")) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions }); } @@ -761,6 +787,12 @@ function invalidateActivityQueries( if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") { queryClient.invalidateQueries({ queryKey: ["routines"] }); + if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) { + const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description"; + queryClient.invalidateQueries({ + queryKey: ["routines", "document-annotations", entityId, documentKey], + }); + } return; } diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 786e1b3ab20..8d1147edacd 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared"; import { buildAssistantPartsFromTranscript, buildIssueChatMessages, + isCoTSegmentActive, stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, @@ -238,6 +239,24 @@ describe("buildAssistantPartsFromTranscript", () => { }]); }); + it("marks only the latest chain-of-thought segment active while a run is live", () => { + expect(isCoTSegmentActive({ + isMessageRunning: true, + segmentIndex: 0, + segmentCount: 2, + })).toBe(false); + expect(isCoTSegmentActive({ + isMessageRunning: true, + segmentIndex: 1, + segmentCount: 2, + })).toBe(true); + expect(isCoTSegmentActive({ + isMessageRunning: false, + segmentIndex: 1, + segmentCount: 2, + })).toBe(false); + }); + it("keeps run errors while suppressing init and system transcript noise", () => { const result = buildAssistantPartsFromTranscript([ { diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index fe2d0c37c04..4993cd709ed 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -538,6 +538,17 @@ export interface SegmentTiming { endMs: number; } +export function isCoTSegmentActive(args: { + isMessageRunning: boolean; + segmentIndex: number; + segmentCount: number; +}) { + const { isMessageRunning, segmentIndex, segmentCount } = args; + if (!isMessageRunning) return false; + if (segmentCount <= 0 || segmentIndex < 0) return true; + return segmentIndex === segmentCount - 1; +} + function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] { const timings: SegmentTiming[] = []; let inSegment = false;