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:
7
.changeset/fn-8401-same-agent-intake.md
Normal file
7
.changeset/fn-8401-same-agent-intake.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
129
packages/core/src/__tests__/same-agent-duplicate-intake.test.ts
Normal file
129
packages/core/src/__tests__/same-agent-duplicate-intake.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user