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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### Conflict handling
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ Default notes:
|
|||||||
| `autoArchiveDoneTasksEnabled` | `boolean` | `true` | Enable periodic auto-archiving of done tasks. |
|
| `autoArchiveDoneTasksEnabled` | `boolean` | `true` | Enable periodic auto-archiving of done tasks. |
|
||||||
| `autoArchiveDoneAfterMs` | `number` | `172800000` | Age in ms after entering done before auto-archive (48h). |
|
| `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`. |
|
| `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. |
|
| `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. |
|
||||||
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
| `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. |
|
| `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:
|
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`.
|
- **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):
|
- **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. The task is never moved to `archived` automatically.
|
- **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): the later task is archived as `auto-resolved-duplicate` and the earliest sibling is kept, exactly as before FN-7658.
|
- **`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.
|
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.
|
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:
|
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 "../builtin-traits.js";
|
||||||
import {validateBranchGroupBranchName} from "../branch-assignment.js";
|
import {validateBranchGroupBranchName} from "../branch-assignment.js";
|
||||||
import {toJson} from "../db.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 {type TaskRow, TASK_COLUMN_DESCRIPTORS} from "../task-store/persistence.js";
|
||||||
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
|
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
|
||||||
import {assertSafeGitBranchName} from "../task-store/shell-safety.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> {
|
export async function _maybeAutoArchiveSameAgentDuplicateBackendImpl(store: TaskStore, task: Task, input: TaskCreateInput,): Promise<void> {
|
||||||
const sourceAgentId = task.sourceAgentId ?? null;
|
// Keep the production backend as wiring only: policy lives in the shared resolver.
|
||||||
const sourceParentTaskId = task.sourceParentTaskId ?? null;
|
return resolveSameAgentDuplicateIntake(store, task, input);
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateBranchGroupImpl(store: TaskStore, id: string, patch: BranchGroupUpdate): Promise<BranchGroup> {
|
export async function updateBranchGroupImpl(store: TaskStore, id: string, patch: BranchGroupUpdate): Promise<BranchGroup> {
|
||||||
if (store.backendMode) {
|
if (store.backendMode) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {InvalidFileScopeError, SelfDefeatingDependencyError, detectSelfDefeating
|
|||||||
import {mkdir, rm, writeFile} from "node:fs/promises";
|
import {mkdir, rm, writeFile} from "node:fs/promises";
|
||||||
import {join} from "node:path";
|
import {join} from "node:path";
|
||||||
import {existsSync} from "node:fs";
|
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 "../builtin-traits.js";
|
||||||
import {applyReviewLevelPreset} from "../review-level-preset.js";
|
import {applyReviewLevelPreset} from "../review-level-preset.js";
|
||||||
import {normalizeTaskPriority} from "../task-priority.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 {resolveEffectiveSettingsById} from "../workflow-settings-resolver.js";
|
||||||
import {getErrorMessage} from "../error-message.js";
|
import {getErrorMessage} from "../error-message.js";
|
||||||
import {generateTaskLineageId} from "../task-lineage.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 {buildBootstrapPrompt} from "../mesh-task-replication.js";
|
||||||
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
|
import {validateFileScopeInPromptContent} from "../task-store/file-scope.js";
|
||||||
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
|
import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js";
|
||||||
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
|
import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-context.js";
|
||||||
import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
|
import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
|
||||||
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.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 {
|
function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
|
||||||
/*
|
/*
|
||||||
@@ -1010,142 +1011,95 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function _maybeAutoArchiveSameAgentDuplicateImpl(store: TaskStore, task: Task, input: TaskCreateInput): Promise<void> {
|
/*
|
||||||
const sourceAgentId = task.sourceAgentId ?? null;
|
FNXC:SameAgentDuplicateIntake 2026-07-19-16:24:
|
||||||
const sourceParentTaskId = task.sourceParentTaskId ?? null;
|
FN-8401 requires PostgreSQL backendMode to use the FN-7658 flag-in-place policy,
|
||||||
// Need at least one provenance handle to scope the dedup check.
|
not its former delete-on-match cleanup. One resolver reads tombstones through
|
||||||
if (!sourceAgentId && !sourceParentTaskId) return;
|
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 {
|
try {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const recent = (await store.listTasks({ slim: true, includeArchived: false })).filter((candidate) => {
|
const settings = await store.getSettings();
|
||||||
if (candidate.id === task.id) return false;
|
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
|
||||||
const createdMs = Date.parse(candidate.createdAt);
|
const allCandidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true });
|
||||||
if (Number.isNaN(createdMs)) return false;
|
const matches = findSameAgentDuplicates(
|
||||||
if (createdMs < nowMs - 24 * 60 * 60 * 1000) return false;
|
{ 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 agentMatch = sourceAgentId != null && candidate.sourceAgentId === sourceAgentId;
|
||||||
const parentMatch = sourceParentTaskId != null && candidate.sourceParentTaskId === sourceParentTaskId;
|
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 tombstonedMatch = matches.find((match) => match.tombstoned && match.allowResurrection !== true);
|
||||||
const stickyWindowDays = Math.max(0, settings.tombstoneStickyWindowDays ?? 7);
|
if (tombstonedMatch?.deletedAt) {
|
||||||
let tombstonedCandidates: Array<{
|
const metadata = {
|
||||||
id: string;
|
matchedTaskId: tombstonedMatch.id, score: tombstonedMatch.score,
|
||||||
title: string | null;
|
tombstoneDeletedAt: tombstonedMatch.deletedAt, stickyWindowDays,
|
||||||
description: string;
|
};
|
||||||
column: Column;
|
if (store.backendMode) {
|
||||||
createdAt: string;
|
await recordRunAuditEventAsync(store.asyncLayer!, {
|
||||||
sourceAgentId: string | null;
|
taskId: task.id, agentId: "system", runId: `store:intake:resurrection-blocked:${task.id}`,
|
||||||
deletedAt: string;
|
domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata,
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (store.isWatching) store.taskCache.delete(task.id);
|
await softDeleteTaskRowAsync(store.asyncLayer!, task.id, new Date().toISOString());
|
||||||
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";
|
|
||||||
} else {
|
} else {
|
||||||
const appliedPatch = await flagSameAgentDuplicate(store, task.id, siblingTaskIds, scores);
|
store.insertRunAuditEventRow({ taskId: task.id, domain: "database", mutationType: "intake:resurrection-blocked", target: task.id, metadata });
|
||||||
if (appliedPatch) {
|
store.deleteTaskById(task.id);
|
||||||
task.sourceMetadata = { ...(task.sourceMetadata ?? {}), ...appliedPatch };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
if (store.isWatching) store.taskCache.delete(task.id);
|
||||||
if (error instanceof TombstonedTaskResurrectionError) {
|
const taskDir = store.taskDir(task.id);
|
||||||
throw error;
|
if (existsSync(taskDir)) await rm(taskDir, { recursive: true, force: true });
|
||||||
}
|
throw new TombstonedTaskResurrectionError(tombstonedMatch.id, tombstonedMatch.deletedAt, false);
|
||||||
storeLog.warn(`FN-4892 same-agent duplicate intake failed open for ${task.id}: ${getErrorMessage(error)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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