FN-8401: preserve same-agent duplicates across create paths

Unify same-agent duplicate intake so live duplicates remain visible and sticky tombstones block recreation on every backend.

- Route SQLite and backend creation through one duplicate-intake resolver
- Flag new live duplicates by default; archive only the new task when explicitly enabled
- Include archived soft-deletes in sticky tombstone matching and cover the backend-safe read
- Document the cross-backend duplicate and resurrection policy

Files changed:
 .changeset/fn-8401-same-agent-intake.md            |   7 +
 docs/architecture.md                               |   2 +-
 docs/settings-reference.md                         |   2 +-
 docs/task-management.md                            |   8 +-
 .../__tests__/same-agent-duplicate-intake.test.ts  | 129 ++++++++++++
 packages/core/src/task-store/remaining-ops-2.ts    |  50 +----
 packages/core/src/task-store/task-creation.ts      | 220 ++++++++-------------
 7 files changed, 233 insertions(+), 185 deletions(-)

Fusion-Task-Id: FN-8401

Fusion-Task-Lineage: 2efa998e-a27d-4013-b42e-e44b7e2316fb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 08:59:53 -07:00
parent c1bc95c1db
commit 104bf69b3b
7 changed files with 232 additions and 184 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Same-agent near-duplicates stay on the board by default on all create paths (no silent auto-archive).
category: fix
dev: Aligns PostgreSQL createTaskBackend same-agent intake with FN-7658 flagSameAgentDuplicate; removes divergent delete-on-match backend path; keeps sticky tombstone near-duplicate blocking on both backends.

View File

@@ -2116,7 +2116,7 @@ The GitHub tracking state listener now attaches to every registered project stor
- Engine-side automated follow-up creation now routes through `packages/engine/src/verification-followup-dedup.ts` instead of calling `TaskStore.createTask()` directly from recovery/eval/PR-comment paths.
- Verification-style follow-ups stamp `sourceMetadata.verificationFailureSignature`, a deterministic SHA-256 digest over `{ lane, sorted failing test basenames }` (or `lane|no-files` when no files can be parsed). Open matches reuse the existing task and append at most one `[verification recurrence]` log entry per hour; closed/done/archived matches within 24 hours create a fresh task with `sourceMetadata.supersedesTaskId` pointing at the prior task.
- Non-verification automated follow-ups can supply `extraMatchKeys` (for example eval `suggestionId` or PR `prNumber`) so dedup stays deterministic even when no test-file signature exists.
- This layer composes with FN-4892 same-agent intake dedup in `@fusion/core`: engine dedup prevents repeated automated recovery spam up front, while store-side same-agent dedup still archives newly-created near-duplicates when `sourceAgentId` is present.
- This layer composes with FN-4892 same-agent intake dedup in `@fusion/core`: engine dedup prevents repeated automated recovery spam up front, while store-side same-agent intake flags newly-created near-duplicates in place by default; only explicit `autoArchiveDuplicateTasksEnabled: true` archives the new task.
- Run-audit emits `verification:followup-created` and `verification:followup-deduped` database events with hashed signature metadata only; no raw stdout/stderr or secret material is persisted in the audit payload.
### Conflict handling

View File

@@ -645,7 +645,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. |
| `autoArchiveDuplicateTasksEnabled` | `boolean` | `false` | FN-7658/FN-8401: gates whether same-agent duplicate intake on every create backend auto-archives the later/new task. Default `false` — the duplicate is flagged in place (`nearDuplicateOf`/`nearDuplicateScore` marker, yellow "Duplicate" chip with Keep/Archive actions), and no live sibling is deleted or archived automatically. Set `true` to restore opt-in archival of the new task only. 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`. Suppressed when the source issue is also the task's *tracked* issue (`githubTracking.enabled` with the same `owner/repo#number`): the GitHub tracking comment already reports completion there, with commit/branch/PR/files details, so the issue would otherwise receive two comments. In that case `githubCommentTemplate` is not used and the task log records `Skipped GitHub issue completion comment`. When tracking points at a *different* issue, both issues are commented as before. |

View File

@@ -153,15 +153,15 @@ 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` (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.
- **Same-agent duplicate intake** (all task-create backends): 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/FN-8401):
- **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. Neither the new task nor its live siblings are moved to `archived` or deleted automatically.
- **`true`** (legacy behavior, opt-in): only the later/new task is archived as `auto-resolved-duplicate`; its live siblings remain intact.
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.
Tombstone-resurrection blocking (including a same-agent near-duplicate of a soft-deleted task within the sticky window) is shared by every task-create backend and is **not** gated by `autoArchiveDuplicateTasksEnabled` — it always throws `TombstonedTaskResurrectionError` regardless of the setting.
Activity + run-audit event types:

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { recordRunAuditEventAsync, softDeleteTaskRowAsync } = vi.hoisted(() => ({
recordRunAuditEventAsync: vi.fn().mockResolvedValue(undefined),
softDeleteTaskRowAsync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../task-store/async-audit.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../task-store/async-audit.js")>()),
recordRunAuditEvent: recordRunAuditEventAsync,
}));
vi.mock("../task-store/async-persistence.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../task-store/async-persistence.js")>()),
softDeleteTaskRow: softDeleteTaskRowAsync,
}));
import { TombstonedTaskResurrectionError } from "../task-store/errors.js";
import { _maybeAutoArchiveSameAgentDuplicateBackendImpl } from "../task-store/remaining-ops-2.js";
import { resolveSameAgentDuplicateIntake } from "../task-store/task-creation.js";
const NOW = new Date().toISOString();
const task = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
title: "Repair same-agent intake policy",
description: "Ensure same-agent duplicate tasks stay visible for human review",
column: "triage",
createdAt: NOW,
sourceAgentId: "agent-intake",
sourceParentTaskId: null,
sourceMetadata: {},
...overrides,
});
function createStore(overrides: Record<string, unknown> = {}) {
const store = {
backendMode: false,
isWatching: false,
asyncLayer: { db: {} },
taskCache: new Map(),
getSettings: vi.fn().mockResolvedValue({ autoArchiveDuplicateTasksEnabled: false, tombstoneStickyWindowDays: 7 }),
listTasks: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
recordActivity: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
insertRunAuditEventRow: vi.fn(),
deleteTaskById: vi.fn(),
taskDir: vi.fn().mockReturnValue("/path-that-does-not-exist"),
...overrides,
};
return store;
}
describe("same-agent duplicate intake policy (FN-8401)", () => {
beforeEach(() => vi.clearAllMocks());
it("does nothing when no provenance handle is present", async () => {
const store = createStore();
const noProvenance = task("FN-NEW", { sourceAgentId: null, sourceParentTaskId: null });
await resolveSameAgentDuplicateIntake(store as any, noProvenance as any, noProvenance as any);
expect(store.listTasks).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
it("flags the new live duplicate in place and never deletes its sibling by default", async () => {
const sibling = task("FN-SIBLING", { createdAt: new Date(Date.now() - 60_000).toISOString() });
const created = task("FN-NEW");
const store = createStore({ listTasks: vi.fn().mockResolvedValue([created, sibling]) });
/*
FNXC:SameAgentDuplicateIntake 2026-07-19-16:33:
The production backend wrapper must remain thin so it cannot reintroduce the
former delete-on-match behavior independently of the shared resolver.
*/
await _maybeAutoArchiveSameAgentDuplicateBackendImpl(store as any, created as any, created as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-NEW", {
sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-SIBLING" }),
});
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({
taskId: "FN-NEW", metadata: expect.objectContaining({ source: "same-agent-flagged" }),
}));
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.deleteTaskById).not.toHaveBeenCalled();
expect((store as any).deleteTask).toBeUndefined();
expect(created.column).toBe("triage");
});
it("archives only the new task when the legacy setting is explicitly enabled", async () => {
const sibling = task("FN-SIBLING", { createdAt: new Date(Date.now() - 60_000).toISOString() });
const created = task("FN-NEW");
const store = createStore({
getSettings: vi.fn().mockResolvedValue({ autoArchiveDuplicateTasksEnabled: true, tombstoneStickyWindowDays: 7 }),
listTasks: vi.fn().mockResolvedValue([created, sibling]),
});
await resolveSameAgentDuplicateIntake(store as any, created as any, created as any);
expect(store.moveTask).toHaveBeenCalledWith("FN-NEW", "archived");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-SIBLING", "archived");
expect(store.deleteTaskById).not.toHaveBeenCalled();
expect(created.column).toBe("archived");
});
it("uses backend-safe tombstone reads and rejects a sticky same-agent resurrection", async () => {
const deletedAt = new Date(Date.now() - 60_000).toISOString();
const tombstone = task("FN-TOMBSTONE", { deletedAt, allowResurrection: false });
const created = task("FN-NEW");
const store = createStore({ backendMode: true, listTasks: vi.fn().mockResolvedValue([created, tombstone]) });
await expect(resolveSameAgentDuplicateIntake(store as any, created as any, created as any))
.rejects.toBeInstanceOf(TombstonedTaskResurrectionError);
/*
FNXC:SameAgentDuplicateIntake 2026-07-19-16:40:
Soft deletes move to `archived`; sticky tombstones require both flags so
same-agent recreation is rejected on every persistence backend.
*/
expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: true, includeDeleted: true });
expect(recordRunAuditEventAsync).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
taskId: "FN-NEW", mutationType: "intake:resurrection-blocked",
}));
expect(softDeleteTaskRowAsync).toHaveBeenCalledWith((store as any).asyncLayer, "FN-NEW", expect.any(String));
expect(store.deleteTaskById).not.toHaveBeenCalled();
});
});

View File

@@ -21,7 +21,7 @@ import {validateSettingValuePatch, WorkflowSettingRejectionError} from "../workf
import "../builtin-traits.js";
import {validateBranchGroupBranchName} from "../branch-assignment.js";
import {toJson} from "../db.js";
import {findSameAgentDuplicates} from "../duplicate-intake.js";
import {resolveSameAgentDuplicateIntake} from "./task-creation.js";
import {type TaskRow, TASK_COLUMN_DESCRIPTORS} from "../task-store/persistence.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {assertSafeGitBranchName} from "../task-store/shell-safety.js";
@@ -208,51 +208,9 @@ export async function writeConfigImpl(store: TaskStore, config: BoardConfig, opt
}
export async function _maybeAutoArchiveSameAgentDuplicateBackendImpl(store: TaskStore, task: Task, input: TaskCreateInput,): Promise<void> {
const sourceAgentId = task.sourceAgentId ?? null;
const sourceParentTaskId = task.sourceParentTaskId ?? null;
if (!sourceAgentId && !sourceParentTaskId) return;
try {
const nowMs = Date.now();
const recent = (await store.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
if (candidate.id === task.id) return false;
const createdMs = Date.parse(candidate.createdAt);
if (Number.isNaN(createdMs)) return false;
if (createdMs < nowMs - 24 * 60 * 60 * 1000) return false;
const agentMatch = sourceAgentId != null && candidate.sourceAgentId === sourceAgentId;
const parentMatch = sourceParentTaskId != null && candidate.sourceParentTaskId === sourceParentTaskId;
return agentMatch || parentMatch;
});
const matches = findSameAgentDuplicates(
{
title: input.title ?? task.title,
description: input.description,
sourceParentTaskId,
},
recent.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: candidate.column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
tombstoned: false,
})),
);
for (const match of matches) {
try {
await store.deleteTask(match.id, { removeLineageReferences: true });
} catch {
// Best-effort dedup cleanup.
}
}
} catch {
// Best-effort; never fail task creation on dedup check.
}
}
// Keep the production backend as wiring only: policy lives in the shared resolver.
return resolveSameAgentDuplicateIntake(store, task, input);
}
export async function updateBranchGroupImpl(store: TaskStore, id: string, patch: BranchGroupUpdate): Promise<BranchGroup> {
if (store.backendMode) {

View File

@@ -11,7 +11,7 @@ import {InvalidFileScopeError, SelfDefeatingDependencyError, detectSelfDefeating
import {mkdir, rm, writeFile} from "node:fs/promises";
import {join} from "node:path";
import {existsSync} from "node:fs";
import type {Task, TaskCreateInput, Column, Settings} from "../types.js";
import type {Task, TaskCreateInput, Settings} from "../types.js";
import "../builtin-traits.js";
import {applyReviewLevelPreset} from "../review-level-preset.js";
import {normalizeTaskPriority} from "../task-priority.js";
@@ -21,13 +21,14 @@ import {resolveTitleSummarizerSettingsModel} from "../model-resolution.js";
import {resolveEffectiveSettingsById} from "../workflow-settings-resolver.js";
import {getErrorMessage} from "../error-message.js";
import {generateTaskLineageId} from "../task-lineage.js";
import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate} from "../duplicate-intake.js";
import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicate-intake.js";
import {buildBootstrapPrompt} from "../mesh-task-replication.js";
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
/*
@@ -1010,142 +1011,95 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
return task;
}
export async function _maybeAutoArchiveSameAgentDuplicateImpl(store: TaskStore, task: Task, input: TaskCreateInput): Promise<void> {
const sourceAgentId = task.sourceAgentId ?? null;
const sourceParentTaskId = task.sourceParentTaskId ?? null;
// Need at least one provenance handle to scope the dedup check.
if (!sourceAgentId && !sourceParentTaskId) return;
/*
FNXC:SameAgentDuplicateIntake 2026-07-19-16:24:
FN-8401 requires PostgreSQL backendMode to use the FN-7658 flag-in-place policy,
not its former delete-on-match cleanup. One resolver reads tombstones through
listTasks(includeDeleted, includeArchived), so FN-5233 sticky near-duplicate blocking
includes soft-deletes whose delete lifecycle puts them in `archived` on both
persistence backends without a synchronous SQLite dependency.
*/
export async function resolveSameAgentDuplicateIntake(store: TaskStore, task: Task, input: TaskCreateInput): Promise<void> {
const sourceAgentId = task.sourceAgentId ?? null;
const sourceParentTaskId = task.sourceParentTaskId ?? null;
if (!sourceAgentId && !sourceParentTaskId) return;
try {
const nowMs = Date.now();
const recent = (await store.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
if (candidate.id === task.id) return false;
const createdMs = Date.parse(candidate.createdAt);
if (Number.isNaN(createdMs)) return false;
if (createdMs < nowMs - 24 * 60 * 60 * 1000) return false;
try {
const nowMs = Date.now();
const settings = await store.getSettings();
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
const allCandidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true });
const matches = findSameAgentDuplicates(
{ title: input.title ?? task.title, description: input.description, sourceParentTaskId },
allCandidates.flatMap<SameAgentDuplicateCandidate>((candidate) => {
if (candidate.id === task.id) return [];
const createdAt = Date.parse(candidate.createdAt);
if (Number.isNaN(createdAt)) return [];
if (candidate.deletedAt) {
const deletedAtMs = Date.parse(candidate.deletedAt);
if (sourceAgentId == null
|| candidate.sourceAgentId !== sourceAgentId
|| Number.isNaN(deletedAtMs)
|| stickyWindowDays <= 0
|| deletedAtMs < nowMs - stickyWindowDays * 24 * 60 * 60 * 1000) return [];
return [{
id: candidate.id, title: candidate.title ?? "", description: candidate.description,
column: candidate.column, createdAt, sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null, tombstoned: true,
deletedAt: candidate.deletedAt, allowResurrection: candidate.allowResurrection === true,
}];
}
const agentMatch = sourceAgentId != null && candidate.sourceAgentId === sourceAgentId;
const parentMatch = sourceParentTaskId != null && candidate.sourceParentTaskId === sourceParentTaskId;
return agentMatch || parentMatch;
});
if (!agentMatch && !parentMatch) return [];
return [{
id: candidate.id, title: candidate.title ?? "", description: candidate.description,
column: candidate.column, createdAt, sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null, tombstoned: false,
}];
}),
{ nowMs, sourceAgentId },
);
if (matches.length === 0) return;
const settings = await store.getSettings();
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
let tombstonedCandidates: Array<{
id: string;
title: string | null;
description: string;
column: Column;
createdAt: string;
sourceAgentId: string | null;
deletedAt: string;
allowResurrection: number | null;
}> = [];
if (stickyWindowDays > 0) {
try {
const cutoffIso = new Date(nowMs - stickyWindowDays * 24 * 60 * 60 * 1000).toISOString();
tombstonedCandidates = store.db.prepare(`
SELECT id, title, description, "column", createdAt, sourceAgentId, deletedAt, allowResurrection
FROM tasks
WHERE deletedAt IS NOT NULL
AND deletedAt >= ?
AND sourceAgentId = ?
AND id != ?
`).all(cutoffIso, sourceAgentId, task.id) as typeof tombstonedCandidates;
} catch (error) {
storeLog.warn(`FN-5233 tombstone candidate widening failed open for ${task.id}: ${getErrorMessage(error)}`);
}
}
const matches = findSameAgentDuplicates(
{
title: input.title ?? task.title,
description: input.description,
sourceParentTaskId,
},
[
...recent.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: candidate.column,
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId ?? null,
sourceParentTaskId: candidate.sourceParentTaskId ?? null,
tombstoned: false,
})),
...tombstonedCandidates.map((candidate) => ({
id: candidate.id,
title: candidate.title ?? "",
description: candidate.description,
column: "todo",
createdAt: Date.parse(candidate.createdAt),
sourceAgentId: candidate.sourceAgentId,
sourceParentTaskId: null,
tombstoned: true,
deletedAt: candidate.deletedAt,
allowResurrection: candidate.allowResurrection === 1,
})),
],
{ nowMs, sourceAgentId },
);
if (matches.length === 0) return;
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
if (tombstonedMatch?.deletedAt) {
store.insertRunAuditEventRow({
taskId: task.id,
domain: "database",
mutationType: "intake:resurrection-blocked",
target: task.id,
metadata: {
matchedTaskId: tombstonedMatch.id,
score: tombstonedMatch.score,
tombstoneDeletedAt: tombstonedMatch.deletedAt,
stickyWindowDays,
},
const tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
if (tombstonedMatch?.deletedAt) {
const metadata = {
matchedTaskId: tombstonedMatch.id, score: tombstonedMatch.score,
tombstoneDeletedAt: tombstonedMatch.deletedAt, stickyWindowDays,
};
if (store.backendMode) {
await recordRunAuditEventAsync(store.asyncLayer!, {
taskId: task.id, agentId: "system", runId: `store:intake:resurrection-blocked:${task.id}`,
domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata,
});
if (store.isWatching) store.taskCache.delete(task.id);
store.deleteTaskById(task.id);
const { rm } = await import("node:fs/promises");
const taskDir = store.taskDir(task.id);
if (existsSync(taskDir)) {
await rm(taskDir, { recursive: true, force: true });
}
throw new TombstonedTaskResurrectionError(
tombstonedMatch.id,
tombstonedMatch.deletedAt,
tombstonedMatch.allowResurrection === true,
);
}
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]));
/*
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(store, task.id, siblingTaskIds, scores);
task.column = "archived";
await softDeleteTaskRowAsync(store.asyncLayer!, task.id, new Date().toISOString());
} else {
const appliedPatch = await flagSameAgentDuplicate(store, task.id, siblingTaskIds, scores);
if (appliedPatch) {
task.sourceMetadata = { ...(task.sourceMetadata ?? {}), ...appliedPatch };
}
store.insertRunAuditEventRow({ taskId: task.id, domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata });
store.deleteTaskById(task.id);
}
} catch (error) {
if (error instanceof TombstonedTaskResurrectionError) {
throw error;
}
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
if (store.isWatching) store.taskCache.delete(task.id);
const taskDir = store.taskDir(task.id);
if (existsSync(taskDir)) await rm(taskDir, { recursive: true, force: true });
throw new TombstonedTaskResurrectionError(tombstonedMatch.id, tombstonedMatch.deletedAt, false);
}
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]));
if (settings.autoArchiveDuplicateTasksEnabled === true) {
await archiveAsSameAgentDuplicate(store, task.id, siblingTaskIds, scores);
task.column = "archived";
} else {
const appliedPatch = await flagSameAgentDuplicate(store, task.id, siblingTaskIds, scores);
if (appliedPatch) task.sourceMetadata = { ...(task.sourceMetadata ?? {}), ...appliedPatch };
}
} catch (error) {
if (error instanceof TombstonedTaskResurrectionError) throw error;
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
}
}
export async function _maybeAutoArchiveSameAgentDuplicateImpl(store: TaskStore, task: Task, input: TaskCreateInput): Promise<void> {
return resolveSameAgentDuplicateIntake(store, task, input);
}