diff --git a/.changeset/fn-7965-duplicate-feedback.md b/.changeset/fn-7965-duplicate-feedback.md new file mode 100644 index 0000000000..0ba1d029f9 --- /dev/null +++ b/.changeset/fn-7965-duplicate-feedback.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Surface duplicate-decision tasks on cards and in the operator mailbox. +category: fix +dev: Triage-marker duplicate decisions now use an idempotent task-linked system message. diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 86dbe0d8af..68778377de 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -430,6 +430,12 @@ The Plan Review "Reviewing" badge must read as an active review state without ad color: var(--text-muted); } +.card-status-badge.needs-user-feedback { + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); +} + .card-status-badge.awaiting-approval { background: var(--status-triage-bg-deep); color: var(--triage); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index b960e079b5..51dc1579ef 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -1263,6 +1263,10 @@ function TaskCardComponent({ const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingRecovery; const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs); const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true); + const isTriageDuplicateDecision = isPaused + && task.pausedReason === "duplicate-decision-required" + && task.sourceMetadata?.duplicateSource === "triage-marker" + && typeof task.sourceMetadata?.nearDuplicateOf === "string"; const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId); const normalizedPriority = normalizeTaskPriorityValue(task.priority); const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY; @@ -2984,9 +2988,15 @@ function TaskCardComponent({
{isPaused && ( - {pausedByAgent ? t("tasks.pausedByAgent", "paused by agent") : t("tasks.paused", "paused")} + {isTriageDuplicateDecision + ? t("tasks.needsUserFeedback", "Needs your decision") + : pausedByAgent ? t("tasks.pausedByAgent", "paused by agent") : t("tasks.paused", "paused")} )} {showStatusBadge && ( diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index a5b4c9dfb8..3c4589116e 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -6175,6 +6175,23 @@ describe("TaskCard provider icons on agent row", () => { }); describe("TaskCard near-duplicate chip", () => { + it("shows a needs-user-feedback status when a triage duplicate is paused for a decision", () => { + render( + , + ); + + expect(screen.getByTestId("card-needs-user-feedback-FN-001")).toHaveTextContent("Needs your decision"); + expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument(); + }); + it("renders duplicate chip when nearDuplicateOf is present", () => { render( { expect(input.content).toContain("https://dash.example/?project=p1&task=FN-1"); }); + it("writes one mailbox message when a triage duplicate needs an operator decision", async () => { + const store = createStore({ ntfyEnabled: false }); + const idempotencyKeys = new Set(); + let insertedCount = 0; + const sendMessageOnce = vi.fn(async (input: any, key: string) => { + const inserted = !idempotencyKeys.has(key); + idempotencyKeys.add(key); + if (inserted) insertedCount += 1; + return { message: { ...input, id: "msg-once-duplicate", read: false, createdAt: "", updatedAt: "" }, inserted }; + }); + const messageStore = Object.assign(new EventEmitter(), { sendMessageOnce }); + const service = new NotificationService(store as any, { projectId: "p1", messageStore: messageStore as any }); + await service.start(); + + const duplicate = task({ + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: "FN-7961" }, + }); + store.emit("task:updated", duplicate); + store.emit("task:updated", duplicate); + await vi.waitFor(() => expect(sendMessageOnce).toHaveBeenCalledTimes(2)); + expect(insertedCount).toBe(1); + + for (const [input, key] of sendMessageOnce.mock.calls) { + expect(key).toBe("triage-duplicate-decision:FN-1"); + expect(input).toMatchObject({ + type: "system", + toId: "dashboard", + toType: "user", + metadata: { taskId: "FN-1", canonicalTaskId: "FN-7961", kind: "triage-duplicate-decision" }, + }); + expect(input.content).toContain("flagged as a duplicate of FN-7961"); + } + }); + it("labels the replan-cap escalation reason in the approval mailbox message", async () => { const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" }); const sendMessageOnce = vi.fn(async (input: any, _key: string) => ({ diff --git a/packages/engine/src/notification/notification-service.ts b/packages/engine/src/notification/notification-service.ts index 66edeb5d0b..09c832d806 100644 --- a/packages/engine/src/notification/notification-service.ts +++ b/packages/engine/src/notification/notification-service.ts @@ -262,6 +262,10 @@ export class NotificationService { void this.writeAwaitingApprovalMailboxMessage(task); } + if (this.isTriageDuplicateDecision(task)) { + void this.writeTriageDuplicateDecisionMailboxMessage(task); + } + if (!this.notificationsEnabled) { return; } @@ -386,6 +390,51 @@ export class NotificationService { } } + private isTriageDuplicateDecision(task: Task): boolean { + return task.paused === true + && task.pausedReason === "duplicate-decision-required" + && task.sourceMetadata?.duplicateSource === "triage-marker" + && typeof task.sourceMetadata.nearDuplicateOf === "string"; + } + + /** Write one durable operator prompt for a duplicate candidate, independent of push configuration. */ + private async writeTriageDuplicateDecisionMailboxMessage(task: Task): Promise { + try { + const messageStore = this.options.messageStore; + if (!messageStore?.sendMessageOnce) { + return; + } + const canonicalTaskId = task.sourceMetadata?.nearDuplicateOf; + if (typeof canonicalTaskId !== "string") { + return; + } + const link = buildNtfyClickUrl({ + dashboardHost: this.dashboardHost, + projectId: this.options.projectId, + taskId: task.id, + }); + const content = [ + `**${formatTaskIdentifier(task)} needs your decision**`, + "", + `It was flagged as a duplicate of ${canonicalTaskId}. Keep it to continue planning, or delete it if the work is already covered.`, + ...(link ? ["", `[Open ${task.id}](${link})`] : []), + ].join("\n"); + await messageStore.sendMessageOnce({ + fromId: "system", + fromType: "system", + toId: DASHBOARD_USER_ID, + toType: "user", + type: "system", + content, + metadata: { taskId: task.id, canonicalTaskId, kind: "triage-duplicate-decision" }, + }, `triage-duplicate-decision:${task.id}`); + } catch (error) { + schedulerLog.log( + `[notify] ${task.id} triage duplicate-decision mailbox message failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + private handleTaskMerged = (result: MergeResult): void => { void this.handleTaskMergedAsync(result); };