FN-5731: require confirmation before archiving near-duplicate tasks
Stop silent near-duplicate auto-archive by routing duplicates through explicit user confirmation in API, engine, and dashboard flows. - add new near-duplicate confirmation metadata/types and preserve duplicate task rows until confirmation - update workflow routes and triage logic to return confirmation-required outcomes instead of immediate archive - add TaskCard and TaskDetailModal UI/actions plus styling and hook updates for confirming or rejecting duplicate handling - expand dashboard and engine tests and task-management docs to cover the new confirmation path Files changed: docs/task-management.md | 14 ++++- packages/core/src/types.ts | 4 ++ packages/dashboard/app/api/legacy.ts | 1 + packages/dashboard/app/components/TaskCard.css | 59 ++++++++++++++++++ packages/dashboard/app/components/TaskCard.tsx | 46 +++++++++++++- packages/dashboard/app/components/TaskDetailModal.css | 34 +++++++++++ packages/dashboard/app/components/TaskDetailModal.tsx | 71 +++++++++++++++++++++- packages/dashboard/app/components/__tests__/TaskCard.test.tsx | 71 ++++++++++++++++++++++ packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx | 68 +++++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 2 +- packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts | 45 ++++++++++++++ packages/dashboard/src/routes/register-task-workflow-routes.ts | 9 ++- packages/engine/src/__tests__/reliability-interactions/near-duplicate-intake.test.ts | 16 +++-- packages/engine/src/triage.ts | 19 +++--- 14 files changed, 433 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-5731 Fusion-Task-Lineage: fa004129-7bce-4457-b8c3-ceb9fb953319
This commit is contained in:
@@ -87,12 +87,20 @@ Layer 1 persists `source.sourceMetadata.intentSignature` on created tasks so lat
|
||||
|
||||
CLI `fn task create` now runs the same near-duplicate intent guard after the FN-4918 deterministic fingerprint guard, using shared `extractIntentSignature` / `findNearDuplicates` helpers from `@fusion/core`. Thresholds and the 7-day comparison window match the dashboard layer exactly. `--no-dedup` remains the single bypass across both duplicate layers: it skips the comparison but still stamps `source.sourceMetadata.intentSignature` when high-signal tokens were extracted. When a near-duplicate is detected, interactive TTY runs prompt `Create anyway? [y/N]`; non-interactive runs refuse creation with exit code 1 and instruct the caller to re-run with `--no-dedup`. The guard is still fail-open: extraction/list/query errors log a warning and continue. `fn task import` (GitHub import) and `fn task plan` intentionally continue to skip both duplicate guards per the FN-5060 same-content-sibling contract.
|
||||
|
||||
Layer 2 runs in triage `finalizeApprovedTask` after `PROMPT.md` is written and parses `## File Scope` as an additional backstop. If the new spec overlaps an older active task on concrete File Scope / intent tokens and still clears the title threshold, the newer task is auto-archived instead of moved to `todo`.
|
||||
Layer 2 runs in triage `finalizeApprovedTask` after `PROMPT.md` is written and parses `## File Scope` as an additional backstop. If the new spec overlaps an older active task on concrete File Scope / intent tokens and still clears the title threshold, the newer task is flagged for user confirmation instead of being silently auto-archived.
|
||||
|
||||
Near-duplicate archival is reversible and leaves lineage markers behind:
|
||||
Near-duplicate flagging now keeps the task in its normal flow column (`todo` / approval flow) and records metadata for UI warnings:
|
||||
|
||||
- `source.sourceMetadata.nearDuplicateOf = <canonicalTaskId>`
|
||||
- activity event `task:auto-archived-near-duplicate`
|
||||
- `source.sourceMetadata.nearDuplicateScore = <number>`
|
||||
- `source.sourceMetadata.nearDuplicateSharedTokens = <string[]>`
|
||||
- optional `source.sourceMetadata.nearDuplicateDismissed = true` after user chooses Keep
|
||||
- activity event `task:near-duplicate-flagged`
|
||||
|
||||
Dashboard surfaces this as a yellow Duplicate chip plus modal actions:
|
||||
|
||||
- **Archive** (user-initiated archive path)
|
||||
- **Keep** (dismisses the warning by setting `nearDuplicateDismissed: true`)
|
||||
|
||||
This layer complements, rather than replaces, FN-4829 similarity detection, FN-4918 deterministic deduplication, and FN-4892 same-agent intake heuristics.
|
||||
|
||||
|
||||
@@ -1024,6 +1024,7 @@ export type ActivityEventType =
|
||||
| "task:duplicate-warning-overridden"
|
||||
| "task:auto-archived-deterministic-duplicate"
|
||||
| "task:auto-archived-near-duplicate"
|
||||
| "task:near-duplicate-flagged"
|
||||
| "task:auto-archived-ghost-bug"
|
||||
| "task:auto-archived-duplicate"
|
||||
| "task:merge-worktree-reacquired"
|
||||
@@ -1660,6 +1661,9 @@ export interface TaskSource {
|
||||
* Reserved metadata keys:
|
||||
* - `duplicateOfTaskIds: string[]` stores structured duplicate lineage captured
|
||||
* from triage parsing and backfills.
|
||||
* - near-duplicate markers: `nearDuplicateOf` (canonical task id),
|
||||
* `nearDuplicateScore` (number), `nearDuplicateSharedTokens` (string[]),
|
||||
* and optional `nearDuplicateDismissed` (boolean).
|
||||
*/
|
||||
sourceMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ export function updateTask(
|
||||
repoOverride?: string | null;
|
||||
issue?: null;
|
||||
} | null;
|
||||
dismissNearDuplicate?: boolean;
|
||||
},
|
||||
projectId?: string,
|
||||
): Promise<Task> {
|
||||
|
||||
@@ -788,12 +788,71 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card-duplicate-chip,
|
||||
.card-duplicate-keep {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
height: var(--card-chip-height);
|
||||
min-height: var(--card-chip-height);
|
||||
border: var(--btn-border-width) solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-duplicate-chip > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.card-time-indicator {
|
||||
border-color: color-mix(in srgb, var(--text-muted) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-duplicate-chip {
|
||||
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.card-duplicate-keep {
|
||||
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-secondary) 90%, transparent);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: none;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-duplicate-keep:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.card-duplicate-keep:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-duplicate-chip {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.card-github-tracking-chip .provider-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@@ -274,7 +274,7 @@ interface TaskCardProps {
|
||||
globalPaused?: boolean;
|
||||
onUpdateTask?: (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean }
|
||||
) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
@@ -492,6 +492,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previousTask.sourceAgentId === nextTask.sourceAgentId &&
|
||||
previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl &&
|
||||
previousTask.sourceMetadata?.agentName === nextTask.sourceMetadata?.agentName &&
|
||||
previousTask.sourceMetadata?.nearDuplicateOf === nextTask.sourceMetadata?.nearDuplicateOf &&
|
||||
previousTask.sourceMetadata?.nearDuplicateDismissed === nextTask.sourceMetadata?.nearDuplicateDismissed &&
|
||||
previousTask.stalledReview?.reason === nextTask.stalledReview?.reason &&
|
||||
previousTask.stalledReview?.heuristic === nextTask.stalledReview?.heuristic &&
|
||||
previousTask.stalledReview?.matchCount === nextTask.stalledReview?.matchCount &&
|
||||
@@ -845,6 +847,10 @@ function TaskCardComponent({
|
||||
const showTrackingIndicator = hasGithubTrackingLink
|
||||
&& !hasMatchingIssueInfoBadge
|
||||
&& !hasMatchingSourceIssue;
|
||||
const showNearDuplicateChip = Boolean(task.sourceMetadata?.nearDuplicateOf)
|
||||
&& task.sourceMetadata?.nearDuplicateDismissed !== true
|
||||
&& task.column !== "archived"
|
||||
&& task.column !== "done";
|
||||
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
|
||||
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
|
||||
const isAgentCreated = isAgentCreatedTask(task);
|
||||
@@ -1225,6 +1231,18 @@ function TaskCardComponent({
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
const handleDismissNearDuplicate = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!onUpdateTask) return;
|
||||
|
||||
try {
|
||||
await onUpdateTask(task.id, { dismissNearDuplicate: true });
|
||||
addToast(`Kept ${task.id}; duplicate warning dismissed`, "success");
|
||||
} catch (err) {
|
||||
addToast(`Failed to keep ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||
}
|
||||
}, [addToast, onUpdateTask, task.id]);
|
||||
|
||||
const handleArchiveClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!onArchiveTask) return;
|
||||
@@ -1934,7 +1952,7 @@ function TaskCardComponent({
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{(filesChangedButton || isGitHubImportedTask || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
|
||||
{(filesChangedButton || isGitHubImportedTask || showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
|
||||
<div className={`card-footer-row${chipFarRight ? " card-footer-row--chip-far-right" : ""}`}>
|
||||
{filesChangedButton}
|
||||
{isGitHubImportedTask && !showLinkedIssueChipForImport && (
|
||||
@@ -1946,8 +1964,30 @@ function TaskCardComponent({
|
||||
<ProviderIcon provider="github" size="sm" />
|
||||
</span>
|
||||
)}
|
||||
{(((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
|
||||
{(showNearDuplicateChip || ((showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue) || (task.retrySummary?.total ?? 0) > 0 || timeIndicator) && (
|
||||
<div className="card-footer-row-right">
|
||||
{showNearDuplicateChip && (
|
||||
<>
|
||||
<span
|
||||
className="card-duplicate-chip"
|
||||
title={`Potential near-duplicate of ${String(task.sourceMetadata?.nearDuplicateOf)}`}
|
||||
aria-label={`Potential near-duplicate of ${String(task.sourceMetadata?.nearDuplicateOf)}`}
|
||||
>
|
||||
<span>{`Duplicate of ${String(task.sourceMetadata?.nearDuplicateOf)}`}</span>
|
||||
</span>
|
||||
{onUpdateTask && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-duplicate-keep"
|
||||
onClick={(e) => void handleDismissNearDuplicate(e)}
|
||||
title="Keep this task and dismiss duplicate warning"
|
||||
aria-label="Keep this task and dismiss duplicate warning"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
|
||||
<a
|
||||
className="card-github-tracking-chip card-github-tracking-link"
|
||||
|
||||
@@ -1908,6 +1908,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__headline {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__copy {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.rebind-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1948,6 +1981,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-near-duplicate-banner__actions,
|
||||
.rebind-banner-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TaskDetailModal.css";
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2 } from "lucide-react";
|
||||
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle } from "lucide-react";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
@@ -542,6 +542,13 @@ export function TaskDetailContent({
|
||||
(task.stuckKillCount ?? 0) > 0 ||
|
||||
(task.recoveryRetryCount ?? 0) > 0 ||
|
||||
Boolean(task.nextRecoveryAt);
|
||||
const nearDuplicateOf = typeof workingTask.sourceMetadata?.nearDuplicateOf === "string"
|
||||
? workingTask.sourceMetadata.nearDuplicateOf
|
||||
: null;
|
||||
const showNearDuplicateWarning = Boolean(nearDuplicateOf)
|
||||
&& workingTask.sourceMetadata?.nearDuplicateDismissed !== true
|
||||
&& task.column !== "archived"
|
||||
&& task.column !== "done";
|
||||
const [sourceAgent, setSourceAgent] = useState<Agent | null>(null);
|
||||
const [selectedSourceAgentId, setSelectedSourceAgentId] = useState<string | null>(null);
|
||||
const provenanceDisplay = getProvenanceLabel(workingTask, {
|
||||
@@ -1695,6 +1702,35 @@ export function TaskDetailContent({
|
||||
}
|
||||
}, [task.id, onDuplicateTask, requestClose, addToast, confirm]);
|
||||
|
||||
const handleDismissNearDuplicate = useCallback(async () => {
|
||||
try {
|
||||
const updatedTask = await updateTask(task.id, { dismissNearDuplicate: true }, projectId);
|
||||
onTaskUpdated?.(updatedTask);
|
||||
addToast(`Kept ${task.id} and dismissed duplicate warning`, "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [task.id, projectId, onTaskUpdated, addToast]);
|
||||
|
||||
const handleArchiveNearDuplicate = useCallback(async () => {
|
||||
if (!onArchiveTask) return;
|
||||
const confirmed = await confirm({
|
||||
title: "Archive near-duplicate task",
|
||||
message: `Archive ${task.id} as a duplicate of ${nearDuplicateOf}?`,
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await onArchiveTask(task.id);
|
||||
addToast(`Archived ${task.id}`, "success");
|
||||
requestClose();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
|
||||
|
||||
const isTaskPaused = task.paused || task.userPaused;
|
||||
const showRecoverBranchBindingBanner = task.column === "in-review" && !task.branch;
|
||||
|
||||
@@ -2421,6 +2457,39 @@ export function TaskDetailContent({
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{showNearDuplicateWarning && (
|
||||
<div className="detail-near-duplicate-banner" role="status" aria-live="polite">
|
||||
<div className="detail-near-duplicate-banner__header">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<span className="detail-near-duplicate-banner__headline">Potential duplicate detected</span>
|
||||
</div>
|
||||
<p className="detail-near-duplicate-banner__copy">
|
||||
This task appears to be a near-duplicate of{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="detail-provenance-link"
|
||||
onClick={() => {
|
||||
if (nearDuplicateOf) {
|
||||
handleDepClick(nearDuplicateOf);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nearDuplicateOf}
|
||||
</button>
|
||||
. Choose Archive to move this task to archived, or Keep to continue with this task.
|
||||
</p>
|
||||
<div className="detail-near-duplicate-banner__actions">
|
||||
{onArchiveTask && (
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleArchiveNearDuplicate()}>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleDismissNearDuplicate()}>
|
||||
Keep
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-meta">
|
||||
<div className="detail-meta-inline-controls" data-testid="detail-meta-inline-controls">
|
||||
<label
|
||||
|
||||
@@ -3932,6 +3932,77 @@ describe("TaskCard provider icons on agent row", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard near-duplicate chip", () => {
|
||||
it("renders duplicate chip when nearDuplicateOf is present", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onUpdateTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides duplicate chip when nearDuplicateDismissed is true", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides duplicate chip in archived and done columns", () => {
|
||||
const { rerender } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "archived", sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onUpdateTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "done", sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onUpdateTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Keep calls updateTask dismissNearDuplicate", async () => {
|
||||
const onUpdateTask = vi.fn().mockResolvedValue(makeTask());
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { dismissNearDuplicate: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard memo comparator provenance behavior", () => {
|
||||
it("returns false when prAuthAvailable changes", () => {
|
||||
const task = makeTask({ column: "in-review" });
|
||||
|
||||
@@ -1904,6 +1904,74 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows near-duplicate banner and keeps warning on Keep click", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdateTask = vi.mocked(updateTask);
|
||||
mockUpdateTask.mockResolvedValueOnce(makeTask({
|
||||
id: "FN-099",
|
||||
sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true },
|
||||
}));
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Potential duplicate detected")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-099", { dismissNearDuplicate: true }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("hides near-duplicate banner once dismissed", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
|
||||
});
|
||||
|
||||
it("archives from near-duplicate banner when confirmed", async () => {
|
||||
const onArchiveTask = vi.fn().mockResolvedValue(makeTask({ column: "archived" }));
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onArchiveTask={onArchiveTask}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Archive" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders corrected stats timing totals in Stats tab", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||
|
||||
@@ -545,7 +545,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const updateTask = useCallback(async (
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean }
|
||||
): Promise<Task> => {
|
||||
const previousTask = tasksRef.current.find((t) => t.id === id);
|
||||
const optimisticTask = previousTask
|
||||
|
||||
@@ -41,6 +41,22 @@ function buildApp(seed: Task[]) {
|
||||
tasks.push(created);
|
||||
return created;
|
||||
}),
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
||||
updateTask: vi.fn().mockImplementation(async (id: string, updates: Record<string, unknown>) => {
|
||||
const index = tasks.findIndex((task) => task.id === id);
|
||||
if (index < 0) throw new Error("Task not found");
|
||||
const current = tasks[index];
|
||||
const sourceMetadataPatch = updates.sourceMetadataPatch as Record<string, unknown> | undefined;
|
||||
const next = {
|
||||
...current,
|
||||
...updates,
|
||||
...(sourceMetadataPatch
|
||||
? { sourceMetadata: { ...(current.sourceMetadata ?? {}), ...sourceMetadataPatch } }
|
||||
: {}),
|
||||
};
|
||||
tasks[index] = next;
|
||||
return next;
|
||||
}),
|
||||
recordActivity: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
@@ -233,4 +249,33 @@ describe("routes /api/tasks near duplicate", () => {
|
||||
expect.objectContaining({ error: "boom" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("PATCH dismissNearDuplicate applies sourceMetadataPatch merge", async () => {
|
||||
const seeded = mkTask({
|
||||
id: "FN-6001",
|
||||
title: "Near duplicate candidate",
|
||||
description: "Test candidate",
|
||||
column: "todo",
|
||||
sourceMetadata: { nearDuplicateOf: "FN-1000" },
|
||||
});
|
||||
const { app, tasks } = buildApp([seeded]);
|
||||
|
||||
const res = await performRequest(
|
||||
app,
|
||||
"PATCH",
|
||||
"/api/tasks/FN-6001",
|
||||
JSON.stringify({ dismissNearDuplicate: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as Task).sourceMetadata).toMatchObject({
|
||||
nearDuplicateOf: "FN-1000",
|
||||
nearDuplicateDismissed: true,
|
||||
});
|
||||
expect(tasks[0]?.sourceMetadata).toMatchObject({
|
||||
nearDuplicateOf: "FN-1000",
|
||||
nearDuplicateDismissed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2723,7 +2723,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status } = req.body;
|
||||
const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body;
|
||||
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
|
||||
|
||||
// Validate model fields are strings or undefined/null
|
||||
@@ -2912,6 +2912,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
validatedStatus = null;
|
||||
}
|
||||
|
||||
if (hasBodyField("dismissNearDuplicate") && dismissNearDuplicate !== undefined && typeof dismissNearDuplicate !== "boolean") {
|
||||
throw new Error("dismissNearDuplicate must be a boolean");
|
||||
}
|
||||
|
||||
const updates: Parameters<typeof scopedStore.updateTask>[1] = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
@@ -2940,6 +2944,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
if (hasBodyField("overlapBlockedBy")) updates.overlapBlockedBy = validatedOverlapBlockedBy;
|
||||
if (hasBodyField("status")) updates.status = validatedStatus;
|
||||
if (dismissNearDuplicate === true) {
|
||||
updates.sourceMetadataPatch = { nearDuplicateDismissed: true };
|
||||
}
|
||||
|
||||
if (hasBodyField("nodeId") && validatedNodeId !== undefined) {
|
||||
const currentTask = await scopedStore.getTask(req.params.id);
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("reliability interactions: near-duplicate intake", () => {
|
||||
while (fixtures.length) await fixtures.pop()!.cleanup();
|
||||
});
|
||||
|
||||
it("archives newer task as near-duplicate and records activity", async () => {
|
||||
it("flags newer task as near-duplicate and records activity", async () => {
|
||||
const fx = await createFixture();
|
||||
fixtures.push(fx);
|
||||
|
||||
@@ -61,10 +61,14 @@ describe("reliability interactions: near-duplicate intake", () => {
|
||||
await (fx.triage as any).finalizeApprovedTask(incoming, basePrompt, await fx.store.getSettings(), {});
|
||||
|
||||
const updated = await fx.store.getTask(incoming.id);
|
||||
expect(updated.column).toBe("archived");
|
||||
expect(updated.column).toBe("todo");
|
||||
expect(updated.sourceMetadata?.nearDuplicateOf).toBeTruthy();
|
||||
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-near-duplicate", limit: 20 });
|
||||
expect(activity.some((entry) => entry.taskId === incoming.id)).toBe(true);
|
||||
expect(typeof updated.sourceMetadata?.nearDuplicateScore).toBe("number");
|
||||
expect(Array.isArray(updated.sourceMetadata?.nearDuplicateSharedTokens)).toBe(true);
|
||||
const flaggedActivity = await fx.store.getActivityLog({ type: "task:near-duplicate-flagged", limit: 20 });
|
||||
expect(flaggedActivity.some((entry) => entry.taskId === incoming.id)).toBe(true);
|
||||
const archivedActivity = await fx.store.getActivityLog({ type: "task:auto-archived-near-duplicate", limit: 20 });
|
||||
expect(archivedActivity.some((entry) => entry.taskId === incoming.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not archive generic file overlap only", async () => {
|
||||
@@ -149,7 +153,7 @@ describe("reliability interactions: near-duplicate intake", () => {
|
||||
expect(updatedNewer.column).toBe("todo");
|
||||
});
|
||||
|
||||
it("archives at most one sibling when both near-duplicates finalize in the same millisecond", async () => {
|
||||
it("does not auto-archive siblings when both near-duplicates finalize in the same millisecond", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00.000Z"));
|
||||
|
||||
@@ -173,7 +177,7 @@ describe("reliability interactions: near-duplicate intake", () => {
|
||||
|
||||
const refreshed = await fx.store.listTasks({ includeArchived: true });
|
||||
const archived = refreshed.filter((task) => task.id === first.id || task.id === second.id).filter((task) => task.column === "archived");
|
||||
expect(archived.length).toBeLessThanOrEqual(1);
|
||||
expect(archived.length).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2560,24 +2560,24 @@ export class TriageProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// FN-5152: only archive the task being finalized when it is the newer sibling,
|
||||
// or the tie-loser when both rows share the same millisecond timestamp.
|
||||
// FN-5152: when the candidate is older (or tie-canonical), flag for user confirmation.
|
||||
if (isStrictlyOlderOrTieCanonical(canonicalTask)) {
|
||||
await this.store.updateTask(task.id, {
|
||||
sourceMetadataPatch: {
|
||||
nearDuplicateOf: canonical.id,
|
||||
nearDuplicateScore: canonical.score,
|
||||
nearDuplicateSharedTokens: canonical.sharedTokens,
|
||||
intentSignature: taskIntentSignature,
|
||||
...(parsedFileScope.length > 0 ? { fileScope: parsedFileScope } : {}),
|
||||
},
|
||||
});
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Auto-archived as near-duplicate of ${canonical.id}`,
|
||||
`Flagged as near-duplicate of ${canonical.id} (awaiting user decision)`,
|
||||
`Shared tokens: ${canonical.sharedTokens.join(", ")}`,
|
||||
);
|
||||
await this.store.moveTask(task.id, "archived");
|
||||
await this.store.recordActivity({
|
||||
type: "task:auto-archived-near-duplicate",
|
||||
type: "task:near-duplicate-flagged",
|
||||
taskId: task.id,
|
||||
taskTitle: task.title ?? "",
|
||||
details: `Near-duplicate of ${canonical.id}`,
|
||||
@@ -2587,17 +2587,14 @@ export class TriageProcessor {
|
||||
score: canonical.score,
|
||||
},
|
||||
});
|
||||
planLog.log(`${task.id} auto-archived as near-duplicate of ${canonical.id}`);
|
||||
return "archived" as const;
|
||||
planLog.log(`${task.id} flagged as near-duplicate of ${canonical.id}; awaiting user decision`);
|
||||
return;
|
||||
}
|
||||
|
||||
planLog.warn(`${task.id}: near-duplicate candidate ${canonical.id} is newer; skipping auto-archive`);
|
||||
planLog.warn(`${task.id}: near-duplicate candidate ${canonical.id} is newer; skipping near-duplicate flag`);
|
||||
})(),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
|
||||
]);
|
||||
if (nearDuplicateResult === "archived") {
|
||||
return;
|
||||
}
|
||||
if (nearDuplicateResult === "timeout") {
|
||||
planLog.warn(`${task.id}: near-duplicate backstop timed out; proceeding`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user