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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 00:23:32 -07:00
parent 511bcaf56d
commit f7d9509294
15 changed files with 241 additions and 11 deletions

View File

@@ -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.

View File

@@ -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`. |

View File

@@ -149,14 +149,20 @@ The duplicate-close task log line remains `Duplicate of <canonicalTaskId> — 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.

View File

@@ -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;

View File

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

View File

@@ -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({

View File

@@ -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<string, number>,
): Promise<Record<string, unknown> | 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;
}

View File

@@ -639,6 +639,7 @@ export type { TaskDependencyMutation } from "./store.js";
export {
findSameAgentDuplicates,
archiveAsSameAgentDuplicate,
flagSameAgentDuplicate,
type SameAgentDuplicateInput,
type SameAgentDuplicateCandidate,
type SameAgentDuplicateMatch,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -180,6 +180,21 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
</select>
<small>{t("settings.scheduling.compactModeKeepsArchiveSizeLowWhilePreserving", "Compact mode keeps archive size low while preserving recent agent activity for context. Default: compact.")}</small>
</div>
{/**
* 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.
*/}
<div className="form-group">
<label htmlFor="autoArchiveDuplicateTasksEnabled" className="checkbox-label">
<input id="autoArchiveDuplicateTasksEnabled" type="checkbox" checked={form.autoArchiveDuplicateTasksEnabled ?? false} onChange={(e) => setForm((f) => ({
...f,
autoArchiveDuplicateTasksEnabled: e.target.checked,
}))}/>{t("settings.scheduling.autoArchiveDuplicateTasks", " Automatically archive duplicate tasks ")}</label>
<small>{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.")}</small>
</div>
<div className="form-group">
<label htmlFor="maxStuckKills">{t("settings.scheduling.maxStuckRetries", "Max Stuck Retries")}</label>
<input id="maxStuckKills" type="number" min={1} step={1} value={form.maxStuckKills ?? ""} onChange={(e) => {

View File

@@ -202,6 +202,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
autoArchiveDoneTasksEnabled: "scheduling.completedTasksOlderThanTheThresholdAreMoved",
autoArchiveDoneAfterMs: "scheduling.numberOfDaysATaskCanStayIn",
archiveAgentLogMode: "scheduling.compactModeKeepsArchiveSizeLowWhilePreserving",
autoArchiveDuplicateTasksEnabled: "scheduling.autoArchiveDuplicateTasksHelp",
maxStuckKills: "scheduling.maximumStuckDetectorRetriesBeforeATaskIs",
groupOverlappingFiles: "scheduling.whenEnabledTasksThatModifyTheSameFiles",
ignoreHiddenOverlapPaths: "scheduling.ignoreHiddenDotPathsHelp",

View File

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

View File

@@ -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": ").",