From f7d950929419717d624f16062d4e99d91e4bee74 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 00:23:32 -0700 Subject: [PATCH] FN-7658: gate same-agent duplicate auto-archiving behind opt-in setting Duplicate tasks created by the same agent are no longer auto-archived by default; they are flagged for review instead, controlled by a new opt-in project setting. - Add project setting `autoArchiveDuplicateTasksEnabled` (default false) gating the FN-4892 same-agent duplicate intake path - Add `flagSameAgentDuplicate` path and `nearDuplicateOf` metadata used when auto-archive is disabled; tombstone-resurrection blocking is unchanged - Wire the setting through core settings schema/types/store, dashboard SchedulingSection UI, and i18n strings - Update docs (settings-reference.md, task-management.md) to describe the new default-off behavior - Add a changeset for the @runfusion/fusion minor release - Extend duplicate-intake, tombstone-window, store-parent-task-dedup, and reliability-interaction tests to cover both flag states Files changed: $(cat /tmp/fn7658_stat.txt) Fusion-Task-Id: FN-7658 Fusion-Task-Lineage: 7d0d1074-1020-48a8-b96f-186154c2c408 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7658-no-auto-archive-duplicates.md | 7 +++ docs/settings-reference.md | 1 + docs/task-management.md | 10 +++- .../duplicate-intake-tombstone-window.test.ts | 40 +++++++++++++- .../src/__tests__/duplicate-intake.test.ts | 54 ++++++++++++++++++- .../__tests__/store-parent-task-dedup.test.ts | 5 +- packages/core/src/duplicate-intake.ts | 49 +++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/settings-schema.ts | 4 ++ packages/core/src/store.ts | 23 ++++++-- packages/core/src/types.ts | 11 ++++ .../settings/sections/SchedulingSection.tsx | 15 ++++++ .../settings-default-descriptions.test.tsx | 1 + .../duplicate-task-auto-archive.test.ts | 29 +++++++++- packages/i18n/locales/en/app.json | 2 + 15 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7658-no-auto-archive-duplicates.md diff --git a/.changeset/fn-7658-no-auto-archive-duplicates.md b/.changeset/fn-7658-no-auto-archive-duplicates.md new file mode 100644 index 0000000000..807be74ea7 --- /dev/null +++ b/.changeset/fn-7658-no-auto-archive-duplicates.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Duplicate tasks are no longer auto-archived on creation by default — they are flagged for review instead. +category: feature +dev: Adds project setting `autoArchiveDuplicateTasksEnabled` (default false) gating the FN-4892 same-agent duplicate intake path in store `_maybeAutoArchiveSameAgentDuplicate`; disabled path uses new `flagSameAgentDuplicate` and sets `nearDuplicateOf` metadata. Tombstone-resurrection blocking is unchanged. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index c613f43de4..7a2d2ab054 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -601,6 +601,7 @@ Default notes: | `autoArchiveDoneTasksEnabled` | `boolean` | `true` | Enable periodic auto-archiving of done tasks. | | `autoArchiveDoneAfterMs` | `number` | `172800000` | Age in ms after entering done before auto-archive (48h). | | `doneAutoArchiveDays` | `number` | `0` | Integer day-based done-task retention. `0` disables day override; values `> 0` take precedence over `autoArchiveDoneAfterMs`. | +| `autoArchiveDuplicateTasksEnabled` | `boolean` | `false` | FN-7658: gates whether same-agent duplicate intake (FN-4892) auto-archives the later task. Default `false` — the duplicate is flagged in place (`nearDuplicateOf`/`nearDuplicateScore` marker, yellow "Duplicate" chip with Keep/Archive actions) instead of being archived automatically. Set `true` to restore the pre-FN-7658 auto-archive behavior. Does not affect ghost-bug preflight or tombstone-resurrection blocking. | | `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. | | `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. | | `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. | diff --git a/docs/task-management.md b/docs/task-management.md index 3ccdc2a402..053e9352b7 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -149,14 +149,20 @@ The duplicate-close task log line remains `Duplicate of — cl Fusion applies two conservative intake heuristics that may auto-archive newly filed tasks before execution starts: - **Ghost-bug preflight** (triage finalize path): for bug-fix-shaped specs that cite concrete constructs/commands, Fusion probes current `main`. If all definitive probes show the cited bug does not reproduce, the task is archived as `auto-resolved-ghost-bug`. -- **Same-agent duplicate intake** (create path): if the same `source.sourceAgentId` filed a highly similar task within 24h (threshold `0.75`), the later task is archived as `auto-resolved-duplicate` and the earliest sibling is kept. +- **Same-agent duplicate intake** (create path): if the same `source.sourceAgentId` (or `source.sourceParentTaskId`) filed a highly similar task within 24h (threshold `0.75`), Fusion still detects the near-duplicate — but what happens next depends on the `autoArchiveDuplicateTasksEnabled` project/global setting (default **`false`**, FN-7658): + - **Default (`false`)**: the later task is left in place and flagged via the same near-duplicate marker used elsewhere (`sourceMetadata.nearDuplicateOf` / `nearDuplicateScore`), so the dashboard's yellow "Duplicate" chip with Keep/Archive actions surfaces it for a human decision. The task is never moved to `archived` automatically. + - **`true`** (legacy behavior, opt-in): the later task is archived as `auto-resolved-duplicate` and the earliest sibling is kept, exactly as before FN-7658. + +Ghost-bug preflight is unaffected by `autoArchiveDuplicateTasksEnabled` — it is a distinct heuristic and always auto-archives on a definitive non-repro. Both heuristics are **fail-open**: probe/detection errors, timeouts, or inconclusive signals do not block normal intake — the task continues in the regular flow. +Tombstone-resurrection blocking (recreating a soft-deleted task within the sticky window) is a separate safety mechanism from same-agent duplicate intake and is **not** gated by `autoArchiveDuplicateTasksEnabled` — it always throws `TombstonedTaskResurrectionError` regardless of the setting. + Activity + run-audit event types: - `task:auto-archived-ghost-bug` -- `task:auto-archived-duplicate` +- `task:auto-archived-duplicate` — emitted for both outcomes of the same-agent duplicate heuristic; the flag-only (default) path sets `metadata.source: "same-agent-flagged"` to distinguish it from the legacy auto-archive outcome. These appear in task activity history; run-audit entries are emitted where run context exists (triage/engine paths). Store-only intake paths record activity without synthetic run context. diff --git a/packages/core/src/__tests__/duplicate-intake-tombstone-window.test.ts b/packages/core/src/__tests__/duplicate-intake-tombstone-window.test.ts index a12dc036b0..dd94a349cc 100644 --- a/packages/core/src/__tests__/duplicate-intake-tombstone-window.test.ts +++ b/packages/core/src/__tests__/duplicate-intake-tombstone-window.test.ts @@ -98,7 +98,7 @@ describe("FN-5233 tombstone sticky-window duplicate intake", () => { })).resolves.toMatchObject({ id: expect.any(String) }); }); - it("keeps live-task duplicate behavior (auto-archive) unchanged", async () => { + it("FN-7658: flags (does not auto-archive) live-task duplicates by default", async () => { const store = harness.store(); const live = await store.createTask({ title: "Live dup", @@ -110,12 +110,48 @@ describe("FN-5233 tombstone sticky-window duplicate intake", () => { description: "duplicate text", source: { sourceType: "unknown", sourceAgentId: "agent-4" }, }); - expect(dup.column).toBe("archived"); + expect(dup.column).not.toBe("archived"); + expect(dup.sourceMetadata?.nearDuplicateOf).toBe(live.id); const events = (store as any).db.prepare("SELECT mutationType FROM runAuditEvents WHERE mutationType = 'intake:resurrection-blocked'").all() as Array<{ mutationType: string }>; expect(events).toHaveLength(0); expect(live.id).not.toBe(dup.id); }); + it("FN-7658: keeps legacy auto-archive behavior when autoArchiveDuplicateTasksEnabled is true", async () => { + const store = harness.store(); + await store.updateSettings({ autoArchiveDuplicateTasksEnabled: true }); + const live = await store.createTask({ + title: "Live dup enabled", + description: "duplicate text enabled", + source: { sourceType: "unknown", sourceAgentId: "agent-4b" }, + }); + const dup = await store.createTask({ + title: "Live dup enabled", + description: "duplicate text enabled", + source: { sourceType: "unknown", sourceAgentId: "agent-4b" }, + }); + expect(dup.column).toBe("archived"); + expect(live.id).not.toBe(dup.id); + }); + + it("FN-7658: tombstone-resurrection blocking still fires when autoArchiveDuplicateTasksEnabled is false", async () => { + const store = harness.store(); + await store.updateSettings({ tombstoneStickyWindowDays: 7, autoArchiveDuplicateTasksEnabled: false }); + + const original = await store.createTask({ + title: "Resurrection guard stays on", + description: "Fix resurrection guard regardless of duplicate archive setting", + source: { sourceType: "unknown", sourceAgentId: "agent-4c" }, + }); + await store.deleteTask(original.id); + + await expect(store.createTask({ + title: "Resurrection guard stays on", + description: "Fix resurrection guard regardless of duplicate archive setting", + source: { sourceType: "unknown", sourceAgentId: "agent-4c" }, + })).rejects.toBeInstanceOf(TombstonedTaskResurrectionError); + }); + it("fails open when tombstone widening query errors", async () => { const store = harness.store(); const db = (store as any).db; diff --git a/packages/core/src/__tests__/duplicate-intake.test.ts b/packages/core/src/__tests__/duplicate-intake.test.ts index 2b95dac851..695e136a16 100644 --- a/packages/core/src/__tests__/duplicate-intake.test.ts +++ b/packages/core/src/__tests__/duplicate-intake.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { findSameAgentDuplicates } from "../duplicate-intake.js"; +import { findSameAgentDuplicates, flagSameAgentDuplicate } from "../duplicate-intake.js"; +import type { TaskStore } from "../store.js"; describe("findSameAgentDuplicates", () => { const nowMs = Date.now(); @@ -116,3 +117,52 @@ describe("findSameAgentDuplicates", () => { expect(matches[0]?.id).toBe("FN-1"); }); }); + +describe("flagSameAgentDuplicate (FN-7658)", () => { + function createMockStore() { + const logEntry = vi.fn().mockResolvedValue(undefined); + const recordActivity = vi.fn().mockResolvedValue(undefined); + const updateTask = vi.fn().mockResolvedValue(undefined); + return { + store: { logEntry, recordActivity, updateTask } as unknown as TaskStore, + logEntry, + recordActivity, + updateTask, + }; + } + + it("logs, records a flag-only activity, and sets the near-duplicate marker without moving the task", async () => { + const { store, logEntry, recordActivity, updateTask } = createMockStore(); + + await flagSameAgentDuplicate(store, "FN-2", ["FN-1"], { "FN-1": 0.9 }); + + expect(logEntry).toHaveBeenCalledTimes(1); + expect(logEntry.mock.calls[0]?.[0]).toBe("FN-2"); + + expect(recordActivity).toHaveBeenCalledTimes(1); + const activity = recordActivity.mock.calls[0]?.[0]; + expect(activity).toMatchObject({ + type: "task:auto-archived-duplicate", + taskId: "FN-2", + metadata: { siblingTaskIds: ["FN-1"], scores: { "FN-1": 0.9 }, source: "same-agent-flagged" }, + }); + + expect(updateTask).toHaveBeenCalledTimes(1); + expect(updateTask).toHaveBeenCalledWith("FN-2", { + sourceMetadataPatch: { nearDuplicateOf: "FN-1", nearDuplicateScore: 0.9 }, + }); + + // Must NOT call moveTask — flagSameAgentDuplicate leaves the task's column alone. + expect((store as unknown as { moveTask?: unknown }).moveTask).toBeUndefined(); + }); + + it("picks the first sibling id as the canonical near-duplicate marker", async () => { + const { store, updateTask } = createMockStore(); + + await flagSameAgentDuplicate(store, "FN-3", ["FN-1", "FN-2"], { "FN-1": 0.8, "FN-2": 0.95 }); + + expect(updateTask).toHaveBeenCalledWith("FN-3", { + sourceMetadataPatch: { nearDuplicateOf: "FN-1", nearDuplicateScore: 0.8 }, + }); + }); +}); diff --git a/packages/core/src/__tests__/store-parent-task-dedup.test.ts b/packages/core/src/__tests__/store-parent-task-dedup.test.ts index f0b2b16d68..9a5e560cfb 100644 --- a/packages/core/src/__tests__/store-parent-task-dedup.test.ts +++ b/packages/core/src/__tests__/store-parent-task-dedup.test.ts @@ -18,7 +18,10 @@ describe("TaskStore parent-task duplicate intake", () => { await harness.afterEach(); }); - it("auto-archives a sibling created by the same parent task with similar description", async () => { + it("auto-archives a sibling created by the same parent task with similar description when autoArchiveDuplicateTasksEnabled is true", async () => { + // FN-7658: default is now flag-in-place; explicitly opt back into the legacy + // auto-archive behavior to cover it is still reachable. + await store.updateSettings({ autoArchiveDuplicateTasksEnabled: true }); const parentId = "FN-PARENT"; const first = await store.createTask({ diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts index 59ed39d583..3ec27e3319 100644 --- a/packages/core/src/duplicate-intake.ts +++ b/packages/core/src/duplicate-intake.ts @@ -108,3 +108,52 @@ export async function archiveAsSameAgentDuplicate( }); await store.moveTask(taskId, "archived"); } + +/** + * FNXC:DuplicateIntake 2026-07-07-00:00 (FN-7658): + * This is the DEFAULT same-agent duplicate intake outcome (the setting + * `autoArchiveDuplicateTasksEnabled` defaults to `false`). Operators do not + * want duplicates silently disappearing into `archived` on creation — they + * want visibility and a chance to decide. Instead of moving the task, this + * records the same near-duplicate marker (`nearDuplicateOf`/`nearDuplicateScore`) + * used elsewhere (see FN-6439 `clearNearDuplicateReferencesTo`) so the dashboard's + * existing yellow "Duplicate" chip with Keep/Archive actions to surface it + * for a human decision. The task is left in whatever column it was created + * in — this function does NOT call `moveTask`. + * + * `siblingIds` should be ordered with the canonical/earliest sibling first; + * that first id becomes `nearDuplicateOf` and its score becomes + * `nearDuplicateScore`. + */ +export async function flagSameAgentDuplicate( + store: TaskStore, + taskId: string, + siblingIds: string[], + scores: Record, +): Promise | undefined> { + const canonicalId = siblingIds[0]; + await store.logEntry( + taskId, + "Flagged as same-agent duplicate", + `Near-duplicate of recently-filed sibling task(s): ${siblingIds.join(", ")} (not archived — autoArchiveDuplicateTasksEnabled is off)`, + ); + // FN-7658: reuse the existing duplicate activity type with a `source` disambiguator + // rather than inventing a schema-unknown activity type; run-audit consumers already + // understand `task:auto-archived-duplicate` and can key off `metadata.source`. + await store.recordActivity({ + type: "task:auto-archived-duplicate", + taskId, + details: "Flagged (not archived) as same-agent duplicate during intake", + metadata: { siblingTaskIds: siblingIds, scores, source: "same-agent-flagged" }, + }); + if (!canonicalId) return undefined; + const sourceMetadataPatch = { + nearDuplicateOf: canonicalId, + nearDuplicateScore: scores[canonicalId] ?? null, + }; + await store.updateTask(taskId, { sourceMetadataPatch }); + // Return the applied patch so the in-memory task object held by the createTask + // caller (which was written to disk BEFORE this flag runs) can be kept in sync + // without a redundant re-fetch. + return sourceMetadataPatch; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 71bf00e303..8e099ef655 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -639,6 +639,7 @@ export type { TaskDependencyMutation } from "./store.js"; export { findSameAgentDuplicates, archiveAsSameAgentDuplicate, + flagSameAgentDuplicate, type SameAgentDuplicateInput, type SameAgentDuplicateCandidate, type SameAgentDuplicateMatch, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index dae587d621..542104a92e 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -518,6 +518,10 @@ export const DEFAULT_PROJECT_SETTINGS = { autoArchiveDoneTasksEnabled: true, autoArchiveDoneAfterMs: 48 * 60 * 60 * 1000, doneAutoArchiveDays: 0, + // FNXC:DuplicateIntake 2026-07-07-00:00 (FN-7658): default OFF — operators + // decide via the near-duplicate flag/UI instead of tasks silently vanishing + // into `archived` during intake. Set true to restore the pre-FN-7658 behavior. + autoArchiveDuplicateTasksEnabled: false, archiveAgentLogMode: "compact", autoUpdatePrStatus: false, githubCommentOnDone: false, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 5213620bd3..e8745ebaed 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -202,7 +202,7 @@ import { } from "./distributed-task-id.js"; import { detectStalledReview } from "./stalled-review-detector.js"; import { computeRetrySummary } from "./retry-summary.js"; -import { archiveAsSameAgentDuplicate, findSameAgentDuplicates } from "./duplicate-intake.js"; +import { archiveAsSameAgentDuplicate, flagSameAgentDuplicate, findSameAgentDuplicates } from "./duplicate-intake.js"; import { isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; import { detectTaskIdIntegrityAnomalies, @@ -5252,8 +5252,25 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const siblingTaskIds = matches.filter((match) => !match.tombstoned).map((match) => match.id); if (siblingTaskIds.length === 0) return; const scores = Object.fromEntries(matches.filter((match) => !match.tombstoned).map((match) => [match.id, match.score])); - await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores); - task.column = "archived"; + /* + FNXC:DuplicateIntake 2026-07-07-00:00 (FN-7658): + Operators do not want same-agent duplicates silently vanishing into `archived` + during intake. Default (`autoArchiveDuplicateTasksEnabled` falsey) flags the + duplicate in place via the near-duplicate marker so a human decides (Keep/Archive + chip). Only an explicit `true` restores the pre-FN-7658 auto-archive behavior. + NOTE: the tombstone-resurrection block above (`TombstonedTaskResurrectionError`) + is a distinct safety mechanism and is intentionally NOT gated by this setting — + it always fires regardless of `autoArchiveDuplicateTasksEnabled`. + */ + if (settings.autoArchiveDuplicateTasksEnabled === true) { + await archiveAsSameAgentDuplicate(this, task.id, siblingTaskIds, scores); + task.column = "archived"; + } else { + const appliedPatch = await flagSameAgentDuplicate(this, task.id, siblingTaskIds, scores); + if (appliedPatch) { + task.sourceMetadata = { ...(task.sourceMetadata ?? {}), ...appliedPatch }; + } + } } catch (error) { if (error instanceof TombstonedTaskResurrectionError) { throw error; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 68844ba4f9..09d4f85573 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4423,6 +4423,17 @@ export interface ProjectSettings { /** Retention in integer days before done tasks are auto-archived. * 0 disables this days-based override. When > 0, takes precedence over autoArchiveDoneAfterMs. */ doneAutoArchiveDays?: number; + /** + * FNXC:DuplicateIntake 2026-07-07-00:00 (FN-7658): + * Operators do not want same-agent duplicate tasks silently archived on + * creation (FN-4892 intake heuristic) — they want visibility and a chance + * to decide. When `true`, `_maybeAutoArchiveSameAgentDuplicate` archives the + * later task as before. When `false` (the default), the heuristic still + * detects the duplicate but flags it in place via the existing near-duplicate + * marker (`nearDuplicateOf`/`nearDuplicateScore`) instead of moving it to + * `archived`, so the dashboard's yellow "Duplicate" chip with Keep/Archive + * actions surfaces it for a human decision. Default: false. */ + autoArchiveDuplicateTasksEnabled?: boolean; /** How much agent log content to preserve when a task is moved to cold archive storage. * - "compact": deterministic summary plus a small recent-entry snapshot (default) * - "full": copy the full agent.log into archive.db diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx index 8c5a1bee61..efe3c098a2 100644 --- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx +++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx @@ -180,6 +180,21 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr {t("settings.scheduling.compactModeKeepsArchiveSizeLowWhilePreserving", "Compact mode keeps archive size low while preserving recent agent activity for context. Default: compact.")} + {/** + * FNXC:DuplicateIntake 2026-07-07-00:00 (FN-7658): + * Operators do not want same-agent duplicate tasks (FN-4892 intake heuristic) + * silently archived on creation — they want visibility and a chance to decide + * via the near-duplicate flag/UI. Default off; this toggle restores the old + * aggressive auto-archive behavior when enabled. + */} +
+ + {t("settings.scheduling.autoArchiveDuplicateTasksHelp", "Automatically archive tasks detected as same-agent duplicates on creation (off by default). When disabled, duplicates are flagged in place with the yellow Duplicate chip and Keep/Archive actions instead of being archived automatically.")} +
{ diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 90f7220152..b58794c915 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -202,6 +202,7 @@ const SETTING_DESCRIPTION_KEYS: Record = { autoArchiveDoneTasksEnabled: "scheduling.completedTasksOlderThanTheThresholdAreMoved", autoArchiveDoneAfterMs: "scheduling.numberOfDaysATaskCanStayIn", archiveAgentLogMode: "scheduling.compactModeKeepsArchiveSizeLowWhilePreserving", + autoArchiveDuplicateTasksEnabled: "scheduling.autoArchiveDuplicateTasksHelp", maxStuckKills: "scheduling.maximumStuckDetectorRetriesBeforeATaskIs", groupOverlappingFiles: "scheduling.whenEnabledTasksThatModifyTheSameFiles", ignoreHiddenOverlapPaths: "scheduling.ignoreHiddenDotPathsHelp", diff --git a/packages/engine/src/__tests__/reliability-interactions/duplicate-task-auto-archive.test.ts b/packages/engine/src/__tests__/reliability-interactions/duplicate-task-auto-archive.test.ts index 5dbc00c4ed..8aa535a270 100644 --- a/packages/engine/src/__tests__/reliability-interactions/duplicate-task-auto-archive.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/duplicate-task-auto-archive.test.ts @@ -25,7 +25,7 @@ describe("reliability interactions: same-agent duplicate intake", () => { while (fixtures.length) await fixtures.pop()!.cleanup(); }); - it("archives later near-duplicate from same agent", async () => { + it("FN-7658: flags (does not auto-archive) later near-duplicate from same agent by default", async () => { const fx = await createStore(); fixtures.push(fx); @@ -40,6 +40,33 @@ describe("reliability interactions: same-agent duplicate intake", () => { source: { sourceType: "agent_heartbeat", sourceAgentId: "agent-x" }, }); + expect((await fx.store.getTask(a.id)).column).toBe("triage"); + const refreshedB = await fx.store.getTask(b.id); + expect(refreshedB.column).not.toBe("archived"); + expect(refreshedB.sourceMetadata?.nearDuplicateOf).toBe(a.id); + const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 10 }); + const entry = activity.find((item) => item.taskId === b.id); + expect(entry).toBeTruthy(); + expect((entry?.metadata as { siblingTaskIds?: string[]; source?: string } | null)?.siblingTaskIds).toEqual([a.id]); + expect((entry?.metadata as { source?: string } | null)?.source).toBe("same-agent-flagged"); + }); + + it("archives later near-duplicate from same agent when autoArchiveDuplicateTasksEnabled is true", async () => { + const fx = await createStore(); + fixtures.push(fx); + await fx.store.updateSettings({ autoArchiveDuplicateTasksEnabled: true }); + + const a = await fx.store.createTask({ + title: "fix: secrets sync typecheck", + description: "typecheck error in secrets-sync", + source: { sourceType: "agent_heartbeat", sourceAgentId: "agent-x" }, + }); + const b = await fx.store.createTask({ + title: "fix: secrets sync typecheck regression", + description: "typecheck error in secrets-sync", + source: { sourceType: "agent_heartbeat", sourceAgentId: "agent-x" }, + }); + expect((await fx.store.getTask(a.id)).column).toBe("triage"); expect((await fx.store.getTask(b.id)).column).toBe("archived"); const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 10 }); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 7e73f1b614..3511a67f99 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6546,6 +6546,8 @@ "addIgnoredPath": " Add ignored path ", "archiveAgentLog": "Archive Agent Log", "archiveCompletedTasksAfterDays": "Archive Completed Tasks After (days)", + "autoArchiveDuplicateTasks": " Automatically archive duplicate tasks ", + "autoArchiveDuplicateTasksHelp": "Automatically archive tasks detected as same-agent duplicates on creation (off by default). When disabled, duplicates are flagged in place with the yellow Duplicate chip and Keep/Archive actions instead of being archived automatically.", "backlogNoTaskAutoClaimIsExecutorOnly": "Backlog/no-task auto-claim is executor-only by default. Enable to let engineer-role agents auto-claim unowned backlog tasks; explicit routing and delegation are unchanged. Default: off.", "browse": " Browse ", "closeParenPeriod": ").",