diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index c84acf77f35..8982e83898d 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -41,6 +41,7 @@ import { import { errorHandler } from "../middleware/index.js"; import { attentionRoutes } from "../routes/attention.js"; import { attentionService } from "../services/attention.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; import { decisionHistoryService } from "../services/decision-history.js"; import { listAttentionExhaustedRuns } from "../services/attention-exhausted-runs.js"; import { agentService } from "../services/agents.js"; @@ -1540,6 +1541,89 @@ describeEmbeddedPostgres("attention service", () => { expect(interactionIds).not.toContain(olderConfirmationId); }); + it.each([ + ["approved from chat", "accept"], + ["expired", "expire"], + ] as const)("does not resurface an older confirmation once the newest one is %s", async (_label, outcome) => { + const { companyId, workerId } = await seedCompany(outcome === "accept" ? "ATR" : "ATX"); + const issueId = await insertIssue({ + companyId, + identifier: outcome === "accept" ? "ATR-1" : "ATX-1", + title: "Re-asked sign-off", + status: "in_review", + assigneeAgentId: workerId, + }); + const olderConfirmationId = randomUUID(); + const newerConfirmationId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: olderConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "none", + createdByAgentId: workerId, + title: "Approve the plan", + payload: { version: 1, prompt: "Approve the plan?" }, + createdAt: new Date("2026-11-05T12:00:00.000Z"), + updatedAt: new Date("2026-11-05T12:00:00.000Z"), + }, + { + id: newerConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "none", + createdByAgentId: workerId, + title: "Approve the plan (again)", + payload: { version: 1, prompt: "Approve the plan?" }, + createdAt: new Date("2026-11-05T12:05:00.000Z"), + updatedAt: new Date("2026-11-05T12:05:00.000Z"), + }, + ]); + + const pendingIds = async () => (await attentionService(db).list(companyId, { userId: "board-user" })).items + .filter((item) => item.sourceKind === "issue_thread_interaction") + .map((item) => item.subject.id); + expect(await pendingIds()).toEqual([newerConfirmationId]); + + if (outcome === "accept") { + // The chat card resolves the same canonical record through the same service. + await issueThreadInteractionService(db).acceptInteraction( + { id: issueId, companyId, projectId: null, goalId: null, status: "in_review" }, + newerConfirmationId, + {}, + { userId: "board-user" }, + ); + } else { + await db.update(issueThreadInteractions).set({ + status: "expired", + result: { version: 1, outcome: "stale_target" }, + resolvedAt: new Date("2026-11-05T12:10:00.000Z"), + updatedAt: new Date("2026-11-05T12:10:00.000Z"), + }).where(eq(issueThreadInteractions.id, newerConfirmationId)); + } + + expect(await pendingIds()).toEqual([]); + }); + + it("keeps a pending tool-action approval visible after a newer tool action is settled", async () => { + const { companyId, workerId } = await seedCompany("ATT"); + const issueId = await insertIssue({ companyId, identifier: "ATT-1", title: "Two tool calls", status: "in_progress", assigneeAgentId: workerId }); + const olderId = randomUUID(); + const base = { companyId, issueId, kind: "request_confirmation" as const, continuationPolicy: "none" as const, createdByAgentId: workerId, title: "Approve tool action" }; + await db.insert(issueThreadInteractions).values([ + { ...base, id: olderId, status: "pending", payload: { version: 1, prompt: "Run A?", toolAction: { actionRequestId: randomUUID() } }, + createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:00:00.000Z") }, + { ...base, id: randomUUID(), status: "accepted", payload: { version: 1, prompt: "Run B?", toolAction: { actionRequestId: randomUUID() } }, + createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:06:00.000Z"), resolvedAt: new Date("2026-11-05T12:06:00.000Z") }, + ]); + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([olderId]); + }); + it("retains every independently bound quality approval alongside the newest ordinary confirmation", async () => { const { companyId, workerId } = await seedCompany("ATQ"); const issueId = await insertIssue({ companyId, identifier: "ATQ-1", title: "Review copy and visual", status: "in_review", assigneeAgentId: workerId }); diff --git a/server/src/services/attention-retention.ts b/server/src/services/attention-retention.ts index ddb74d682e5..378302faca3 100644 --- a/server/src/services/attention-retention.ts +++ b/server/src/services/attention-retention.ts @@ -2,7 +2,7 @@ import { and, eq, getTableColumns, inArray, isNull, sql, type SQL } from "drizzl import { agents, decisionRetention, decisions, heartbeatRuns, issueApprovals, issueRecoveryActions, issueThreadInteractions, issues, approvals, type Db } from "@paperclipai/db"; import { evaluateAgentInvokability } from "./agent-invokability.js"; -import { HUMAN_RECOVERY_OWNER_TYPES, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, listFailedRunsWithNewerRun, readRunIssueId, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; +import { HUMAN_RECOVERY_OWNER_TYPES, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, listFailedRunsWithNewerRun, loadNewestSettledConfirmationAt, readRunIssueId, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; import { decisionRetentionService, DEFAULT_DECISION_ARCHIVE_DAYS } from "./decision-retention.js"; import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js"; import { DELIVERY_QUEUES } from "./delivery-work-notifications.js"; @@ -116,7 +116,8 @@ export function attentionRetentionService(db: Db) { eq(issueThreadInteractions.companyId, companyId), inArray(issueThreadInteractions.issueId, confirmationIssueIds), eq(issueThreadInteractions.status, "pending"), eq(issueThreadInteractions.kind, "request_confirmation"), isNull(issueThreadInteractions.addresseeUserId))) : []; - const newest = new Set(collapsePendingConfirmationsToNewest(siblings.filter(boardAddressed)).map(row => row.id)); + const newest = new Set(collapsePendingConfirmationsToNewest(siblings.filter(boardAddressed), + await loadNewestSettledConfirmationAt(database, companyId, confirmationIssueIds)).map(row => row.id)); for (const row of rows) result.set(row.id, visibleIds.has(row.issueId) && boardAddressed(row) && (row.kind !== "request_confirmation" || newest.has(row.id))); } diff --git a/server/src/services/attention-source-eligibility.ts b/server/src/services/attention-source-eligibility.ts index 971ca5f1ee5..4505a31bbd5 100644 --- a/server/src/services/attention-source-eligibility.ts +++ b/server/src/services/attention-source-eligibility.ts @@ -1,5 +1,5 @@ import { and, eq, gt, inArray, sql, type SQL } from "drizzle-orm"; -import { heartbeatRuns, issueRecoveryActions, type Db } from "@paperclipai/db"; +import { heartbeatRuns, issueRecoveryActions, issueThreadInteractions, type Db } from "@paperclipai/db"; export const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; const OPEN_RECOVERY_ATTENTION_STATUSES = ["active", "escalated"] as const; @@ -11,14 +11,26 @@ export function recoveryActionAttentionCondition() { export const ATTENTION_BUDGET_WARNING_PERCENT = 85; export const ATTENTION_AGENT_ERROR_STATUS = "error"; +function payloadFlag(payload: unknown, key: string) { + return Boolean(payload && typeof payload === "object" && key in payload && (payload as Record)[key]); +} + +/** + * A pending confirmation is superseded once the same issue has a newer + * ordinary confirmation — pending, or already answered/expired. Without the + * settled case, answering the newest card (from chat or anywhere else) + * resurfaces the older ask it replaced. Quality approvals stay + * independent; tool-action approvals are gates for distinct calls, so a settled + * one never hides another. + */ export function collapsePendingConfirmationsToNewest(rows: T[]) { - const independent = (row: T) => Boolean(row.payload && typeof row.payload === "object" && "qualityReview" in row.payload && row.payload.qualityReview); +}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap = new Map()) { + const independent = (row: T) => payloadFlag(row.payload, "qualityReview"); const newestByGroup = new Map(); for (const row of rows) { if (row.kind !== "request_confirmation" || independent(row)) continue; @@ -33,11 +45,33 @@ export function collapsePendingConfirmationsToNewest ( - row.kind !== "request_confirmation" - || independent(row) - || newestByGroup.get(`${row.issueId}:${row.kind}`)?.id === row.id - )); + return rows.filter((row) => { + if (row.kind !== "request_confirmation" || independent(row)) return true; + if (newestByGroup.get(`${row.issueId}:${row.kind}`)?.id !== row.id) return false; + const settledAt = payloadFlag(row.payload, "toolAction") ? undefined : newestSettledConfirmationAt.get(row.issueId); + return !settledAt || settledAt.getTime() <= row.createdAt.getTime(); + }); +} + +// Cancelled/failed replacements do not settle the ask, so an older card returns. +const SETTLED_CONFIRMATION_STATUSES = ["accepted", "rejected", "answered", "expired"] as const; + +/** Newest answered/expired ordinary confirmation per issue (see the collapse above). */ +export async function loadNewestSettledConfirmationAt(db: Db, companyId: string, issueIds: string[]) { + const ids = [...new Set(issueIds)]; + if (ids.length === 0) return new Map(); + const rows = await db.select({ + issueId: issueThreadInteractions.issueId, + createdAt: sql`max(${issueThreadInteractions.createdAt})::text`, + }).from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, companyId), + inArray(issueThreadInteractions.issueId, ids), + eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]), + sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`, + sql`jsonb_typeof(${issueThreadInteractions.payload}->'toolAction') is distinct from 'object'`, + )).groupBy(issueThreadInteractions.issueId); + return new Map(rows.map((row) => [row.issueId, new Date(row.createdAt)])); } export function readRunIssueId(contextSnapshot: Record | null) { diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index bbbf505f480..1f24e5d5881 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -1,4 +1,4 @@ -import { recoveryActionAttentionCondition, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; +import { recoveryActionAttentionCondition, ATTENTION_BUDGET_WARNING_PERCENT, ATTENTION_AGENT_ERROR_STATUS, collapsePendingConfirmationsToNewest, loadNewestSettledConfirmationAt, readRunIssueId, listFailedRunsWithNewerRun, requiresReviewAttention, terminalBlockerRequiresAttention } from "./attention-source-eligibility.js"; import { approvalExecutionIssueCondition, firstApprovalIssueId } from "./approval-visibility.js"; import { and, asc, desc, eq, inArray, isNull, notInArray, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -1223,7 +1223,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions !evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable) && (row.addresseeUserId === null || row.addresseeUserId === options.userId) ); - const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows); + const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows, await loadNewestSettledConfirmationAt( + db, companyId, boardInteractionRows.filter((row) => row.kind === "request_confirmation").map((row) => row.issueId), + )); const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([ // A pending human request remains actionable in a persistent chat even // though its idle container is intentionally absent from work queues.