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);
};