mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
fix(attention): keep replaced confirmations out of Needs you after the newest is settled
Needs you showed only the newest pending request_confirmation per issue. When that card was approved (from chat or anywhere) or expired, the older ask it replaced became the newest pending card and returned to Needs you. The collapse now also counts the newest answered or expired ordinary confirmation. Cancelled or failed replacements still let the older ask return. Tool-action and quality approvals keep their own visibility. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
819420a60a
commit
ebe8e0c0ae
4 files changed
+133
-12
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)[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<T extends {
|
||||
id: string;
|
||||
issueId: string;
|
||||
kind: string;
|
||||
createdAt: Date;
|
||||
payload: unknown;
|
||||
}>(rows: T[]) {
|
||||
const independent = (row: T) => Boolean(row.payload && typeof row.payload === "object" && "qualityReview" in row.payload && row.payload.qualityReview);
|
||||
}>(rows: T[], newestSettledConfirmationAt: ReadonlyMap<string, Date> = new Map()) {
|
||||
const independent = (row: T) => payloadFlag(row.payload, "qualityReview");
|
||||
const newestByGroup = new Map<string, T>();
|
||||
for (const row of rows) {
|
||||
if (row.kind !== "request_confirmation" || independent(row)) continue;
|
||||
@@ -33,11 +45,33 @@ export function collapsePendingConfirmationsToNewest<T extends {
|
||||
}
|
||||
}
|
||||
|
||||
return rows.filter((row) => (
|
||||
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<string, Date>();
|
||||
const rows = await db.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
createdAt: sql<string>`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<string, unknown> | null) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user