fix(FN-7965): surface duplicate decisions
Show a clear operator-decision badge and deliver an idempotent mailbox prompt for triage duplicate markers. Fusion-Task-Id: FN-7965
This commit is contained in:
@@ -262,6 +262,42 @@ describe("NotificationService", () => {
|
||||
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<string>();
|
||||
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) => ({
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user