From 104bf69b3bdb6ecf32deb56373b714983bd89a6e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 08:59:53 -0700 Subject: [PATCH] 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) --- .changeset/fn-8401-same-agent-intake.md | 7 + docs/architecture.md | 2 +- docs/settings-reference.md | 2 +- docs/task-management.md | 8 +- .../same-agent-duplicate-intake.test.ts | 129 +++++++++++ .../core/src/task-store/remaining-ops-2.ts | 50 +--- packages/core/src/task-store/task-creation.ts | 218 +++++++----------- 7 files changed, 232 insertions(+), 184 deletions(-) create mode 100644 .changeset/fn-8401-same-agent-intake.md create mode 100644 packages/core/src/__tests__/same-agent-duplicate-intake.test.ts diff --git a/.changeset/fn-8401-same-agent-intake.md b/.changeset/fn-8401-same-agent-intake.md new file mode 100644 index 0000000000..6095637820 --- /dev/null +++ b/.changeset/fn-8401-same-agent-intake.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 365dff5e1a..fb2460ffc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 1757ae18df..a168b3dd0f 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -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. | diff --git a/docs/task-management.md b/docs/task-management.md index 55b6fc194c..5d77215140 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -153,15 +153,15 @@ 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` (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: diff --git a/packages/core/src/__tests__/same-agent-duplicate-intake.test.ts b/packages/core/src/__tests__/same-agent-duplicate-intake.test.ts new file mode 100644 index 0000000000..28ecb2d85b --- /dev/null +++ b/packages/core/src/__tests__/same-agent-duplicate-intake.test.ts @@ -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()), + recordRunAuditEvent: recordRunAuditEventAsync, +})); +vi.mock("../task-store/async-persistence.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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 = {}) => ({ + 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 = {}) { + 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(); + }); +}); diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index 4a4aa9846c..3e960ba7b3 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -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 { - 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 { if (store.backendMode) { diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 116fc67a98..fd3ca349c9 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -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 { - 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 { + 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((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 { + return resolveSameAgentDuplicateIntake(store, task, input); +}