FN-8440: preserve acknowledged duplicate decisions
Keep an operator's same-canonical duplicate decision durable across triage and maintenance reprocessing. - Preserve acknowledged Keep decisions for the same canonical while prompting for a new canonical. - Clear acknowledged duplicate markers without overriding manual pauses. - Export the acknowledgement helper through both core barrels for engine gate loading. - Add regression coverage, operator documentation, and a patch changeset. Files changed: .changeset/fn-8440-persist-duplicate-decision.md | 7 ++ docs/task-management.md | 2 + .../core/src/__tests__/duplicate-intake.test.ts | 16 +++++ packages/core/src/duplicate-intake.ts | 27 ++++++-- packages/core/src/index.gate.ts | 1 + packages/core/src/index.ts | 1 + .../explicit-duplicate-marker-sweep.test.ts | 59 +++++++++++++++++ .../triage-explicit-duplicate-marker.test.ts | 77 ++++++++++++++++++++-- packages/engine/src/self-healing.ts | 21 +++++- packages/engine/src/triage.ts | 21 ++++++ 10 files changed, 221 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-8440 Fusion-Task-Lineage: 966d7c09-8651-463c-840d-707f2bafd2a7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8440-persist-duplicate-decision.md
Normal file
7
.changeset/fn-8440-persist-duplicate-decision.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Persist operator duplicate decisions so Fusion does not re-ask for the same task.
|
||||
category: fix
|
||||
dev: Honor nearDuplicateDismissed for the same nearDuplicateOf across triage finalize and explicit-marker self-healing (FN-8440).
|
||||
@@ -138,6 +138,8 @@ Layer behavior:
|
||||
- **Triage planning loop** — after triage reads the generated `PROMPT.md`, an exact redirect marker short-circuits directly into `finalizeApprovedTask()`. Normal plans run deterministic spec hygiene checks in triage, then the selected workflow's optional Plan Review gate owns AI plan review before execution.
|
||||
- **Self-healing sweep** — maintenance Batch 2 runs `resolveExplicitDuplicateMarkerTasks()` across `triage`/`todo` tasks to clean up older stuck marker tasks. The sweep is best-effort, capped at 50 marker tasks per cycle, and can be disabled with the internal setting `resolveExplicitDuplicateMarkerEnabled: false` (default `true`).
|
||||
|
||||
An operator's decision is durable for a task and its active canonical pair. **Keep** records the acknowledgement, clears the marker-only prompt and triage decision hold, and lets planning continue; triage and self-healing will not ask again if that same marker is reprocessed. A marker for a different active canonical remains a new decision. **Delete** for an explicit-marker decision soft-deletes the duplicate, while **Archive** for an ordinary near-duplicate leaves it terminal in Archived; neither outcome is reopened as a duplicate decision.
|
||||
|
||||
All three layers fail open: parse errors, task lookup failures, file-read failures, activity-recording errors, or other unexpected exceptions log a warning and continue normal intake/triage/self-healing flow instead of blocking task creation or recovery.
|
||||
|
||||
Activity uses the existing `task:auto-archived-duplicate` event with `metadata.source` disambiguators:
|
||||
|
||||
@@ -257,4 +257,20 @@ describe("flagTriageDuplicate", () => {
|
||||
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ source: "triage-marker-flagged", canonicalTaskId: "FN-1" }) }));
|
||||
expect(store.deleteTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a same-canonical Keep acknowledgement when re-flagged", async () => {
|
||||
const { flagTriageDuplicate } = await import("../duplicate-intake.js");
|
||||
const store = {
|
||||
getTask: vi.fn().mockResolvedValue({ sourceMetadata: { nearDuplicateOf: "fn-1", nearDuplicateDismissed: true } }),
|
||||
logEntry: vi.fn(),
|
||||
recordActivity: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await flagTriageDuplicate(store, "FN-2", "FN-1");
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-2", {
|
||||
sourceMetadataPatch: expect.objectContaining({ nearDuplicateDismissed: true, nearDuplicateOf: "FN-1" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,21 +277,38 @@ export async function flagSameAgentDuplicate(
|
||||
|
||||
|
||||
/**
|
||||
* FNXC:DuplicateIntake 2026-07-16-13:00:
|
||||
* Issue #2225 keeps triage-marker duplicates visible for an operator decision. Reuse the
|
||||
* near-duplicate metadata consumed by the existing linked banner/chip; never move or delete here.
|
||||
* Reset a prior Keep acknowledgement so a later marker cannot leave the task blocked without its UI.
|
||||
* FNXC:DuplicateIntake 2026-07-20-12:00:
|
||||
* FN-8440 makes an operator Keep acknowledgement durable for exactly one task/canonical pair.
|
||||
* Case-insensitive comparison matches task-id handling elsewhere: replan and maintenance must not
|
||||
* recreate the decision hold for that same pair, while a new canonical remains actionable.
|
||||
*/
|
||||
export function isTriageDuplicateKeepAcknowledged(
|
||||
sourceMetadata: Record<string, unknown> | null | undefined,
|
||||
canonicalId: string,
|
||||
): boolean {
|
||||
return sourceMetadata?.nearDuplicateDismissed === true
|
||||
&& typeof sourceMetadata.nearDuplicateOf === "string"
|
||||
&& sourceMetadata.nearDuplicateOf.toLowerCase() === canonicalId.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:DuplicateIntake 2026-07-20-12:00:
|
||||
* FN-8440 forbids re-asking after Keep for the same task/canonical pair. A marker for a different
|
||||
* canonical deliberately resets acknowledgement so its operator decision can still be surfaced.
|
||||
*/
|
||||
export async function flagTriageDuplicate(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
canonicalId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const existing = typeof store.getTask === "function"
|
||||
? await store.getTask(taskId).catch(() => null)
|
||||
: null;
|
||||
const sourceMetadataPatch = {
|
||||
nearDuplicateOf: canonicalId,
|
||||
nearDuplicateScore: 1,
|
||||
duplicateSource: "triage-marker",
|
||||
nearDuplicateDismissed: false,
|
||||
nearDuplicateDismissed: isTriageDuplicateKeepAcknowledged(existing?.sourceMetadata, canonicalId),
|
||||
};
|
||||
await store.logEntry(taskId, "Flagged as triage duplicate", `Duplicate marker points to ${canonicalId}; awaiting operator decision`);
|
||||
await store.recordActivity({
|
||||
|
||||
@@ -732,6 +732,7 @@ export {
|
||||
archiveAsSameAgentDuplicate,
|
||||
flagSameAgentDuplicate,
|
||||
flagTriageDuplicate,
|
||||
isTriageDuplicateKeepAcknowledged,
|
||||
type SameAgentDuplicateInput,
|
||||
type SameAgentDuplicateCandidate,
|
||||
type SameAgentDuplicateMatch,
|
||||
|
||||
@@ -830,6 +830,7 @@ export {
|
||||
archiveAsSameAgentDuplicate,
|
||||
flagSameAgentDuplicate,
|
||||
flagTriageDuplicate,
|
||||
isTriageDuplicateKeepAcknowledged,
|
||||
type SameAgentDuplicateInput,
|
||||
type SameAgentDuplicateCandidate,
|
||||
type SameAgentDuplicateMatch,
|
||||
|
||||
@@ -130,6 +130,65 @@ const canRun = hasGit && hasPg;
|
||||
expect(existsSync(promptPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-pause a same-canonical Keep acknowledgement during maintenance", async () => {
|
||||
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } });
|
||||
fixtures.push(fx);
|
||||
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5302", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md");
|
||||
await fx.store.updateTask(duplicate.id, {
|
||||
sourceMetadataPatch: { nearDuplicateOf: canonical.id.toLowerCase(), duplicateSource: "triage-marker", nearDuplicateDismissed: true },
|
||||
});
|
||||
|
||||
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
|
||||
|
||||
const updated = await fx.store.getTask(duplicate.id);
|
||||
expect(updated.paused).not.toBe(true);
|
||||
expect(updated.pausedReason ?? null).toBeNull();
|
||||
expect(updated.sourceMetadata).toEqual(expect.objectContaining({ nearDuplicateOf: canonical.id.toLowerCase(), nearDuplicateDismissed: true }));
|
||||
expect(existsSync(promptPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("still prompts when a marker names a different active canonical", async () => {
|
||||
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } });
|
||||
fixtures.push(fx);
|
||||
|
||||
const originalCanonical = await fx.store.createTask({ title: "Original canonical", description: "original", column: "todo" });
|
||||
const canonical = await fx.store.createTask({ title: "New canonical", description: "new", column: "todo" });
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5303", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
await fx.store.updateTask(duplicate.id, {
|
||||
sourceMetadataPatch: { nearDuplicateOf: originalCanonical.id, duplicateSource: "triage-marker", nearDuplicateDismissed: true },
|
||||
});
|
||||
|
||||
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
|
||||
|
||||
expect(await fx.store.getTask(duplicate.id)).toMatchObject({
|
||||
paused: true,
|
||||
pausedReason: "duplicate-decision-required",
|
||||
sourceMetadata: expect.objectContaining({ nearDuplicateOf: canonical.id, nearDuplicateDismissed: false }),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a user pause while cleaning an acknowledged marker", async () => {
|
||||
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } });
|
||||
fixtures.push(fx);
|
||||
|
||||
const canonical = await fx.store.createTask({ title: "Canonical", description: "canonical", column: "todo" });
|
||||
const duplicate = await createPromptTask(fx, { id: "FN-5304", column: "triage", prompt: duplicateStub(canonical.id) });
|
||||
const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md");
|
||||
await fx.store.updateTask(duplicate.id, {
|
||||
paused: true,
|
||||
pausedReason: "manual",
|
||||
sourceMetadataPatch: { nearDuplicateOf: canonical.id, duplicateSource: "triage-marker", nearDuplicateDismissed: true },
|
||||
});
|
||||
|
||||
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
|
||||
|
||||
expect(await fx.store.getTask(duplicate.id)).toMatchObject({ paused: true, pausedReason: "manual" });
|
||||
expect(existsSync(promptPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves full specs untouched", async () => {
|
||||
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
|
||||
fixtures.push(fx);
|
||||
|
||||
@@ -9,8 +9,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false } as Settings),
|
||||
logEntry: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
deleteTaskIf: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
recordActivity: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise<unknown>) => await operation()),
|
||||
readTaskForMove: vi.fn().mockImplementation(async () => createTask()),
|
||||
moveTask: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
@@ -57,7 +60,7 @@ describe("triage explicit duplicate marker short-circuit", () => {
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "delete" })).resolves.toBe(true);
|
||||
|
||||
expect(store.deleteTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
|
||||
expect((store as any).deleteTaskIf).toHaveBeenCalledWith("FN-002", expect.any(Function), expect.objectContaining({
|
||||
removeLineageReferences: true,
|
||||
auditContext: expect.objectContaining({
|
||||
agentId: "triage",
|
||||
@@ -83,20 +86,84 @@ describe("triage explicit duplicate marker short-circuit", () => {
|
||||
|
||||
it("keeps a marker duplicate by clearing its system pause for replanning", async () => {
|
||||
const canonical = createTask({ id: "FN-001", column: "todo" });
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) });
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "keep" })).resolves.toBe(true);
|
||||
const task = createTask();
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task),
|
||||
withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise<unknown>) => await operation()),
|
||||
readTaskForMove: vi.fn().mockResolvedValue(task),
|
||||
});
|
||||
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "keep" })).resolves.toBe(true);
|
||||
expect(store.deleteTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, status: null }));
|
||||
});
|
||||
|
||||
it("does not re-pause a same-canonical Keep acknowledgement after marker reprocessing", async () => {
|
||||
const canonical = createTask({ id: "FN-001", column: "todo" });
|
||||
const task = createTask({
|
||||
sourceMetadata: { nearDuplicateOf: "fn-001", duplicateSource: "triage-marker", nearDuplicateDismissed: true },
|
||||
});
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task),
|
||||
withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise<unknown>) => await operation()),
|
||||
readTaskForMove: vi.fn().mockResolvedValue(task),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
|
||||
paused: false,
|
||||
pausedReason: null,
|
||||
sourceMetadataPatch: { nearDuplicateDismissed: true },
|
||||
}));
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true }));
|
||||
});
|
||||
|
||||
it("prompts when a reprocessed marker names a different active canonical", async () => {
|
||||
const canonical = createTask({ id: "FN-001", column: "todo" });
|
||||
const task = createTask({
|
||||
sourceMetadata: { nearDuplicateOf: "FN-003", duplicateSource: "triage-marker", nearDuplicateDismissed: true },
|
||||
});
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) });
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
|
||||
paused: true,
|
||||
pausedReason: "duplicate-decision-required",
|
||||
sourceMetadataPatch: expect.objectContaining({ nearDuplicateDismissed: false, nearDuplicateOf: "FN-001" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("preserves a user pause when reprocessing an acknowledged marker", async () => {
|
||||
const canonical = createTask({ id: "FN-001", column: "todo" });
|
||||
const task = createTask({
|
||||
paused: true,
|
||||
pausedReason: "manual",
|
||||
userPaused: true,
|
||||
sourceMetadata: { nearDuplicateOf: "FN-001", duplicateSource: "triage-marker", nearDuplicateDismissed: true },
|
||||
});
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(canonical),
|
||||
readTaskForMove: vi.fn().mockResolvedValue(task),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
|
||||
|
||||
expect(store.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
["missing", null],
|
||||
["soft-deleted", createTask({ id: "FN-001", deletedAt: new Date().toISOString() })],
|
||||
["done", createTask({ id: "FN-001", column: "done" })],
|
||||
["archived", createTask({ id: "FN-001", column: "archived" })],
|
||||
])("clears an inactive %s canonical marker instead of pausing for a hidden decision", async (_state, canonical) => {
|
||||
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) });
|
||||
const task = createTask();
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockImplementation(async (id: string) => id === "FN-001" ? canonical : task),
|
||||
readTaskForMove: vi.fn().mockResolvedValue(task),
|
||||
});
|
||||
|
||||
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true);
|
||||
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
|
||||
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-002", {
|
||||
paused: false,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, resolveReboundTarget, planLegacyAdoption, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, resolveReboundTarget, planLegacyAdoption, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
@@ -12083,6 +12083,25 @@ export class SelfHealingManager {
|
||||
}
|
||||
|
||||
const resolution = settings.triageDuplicateResolution ?? "prompt";
|
||||
/*
|
||||
FNXC:DuplicateIntake 2026-07-20-12:00:
|
||||
FN-8440 forbids self-healing from re-arming a duplicate-decision-required hold after
|
||||
an operator kept this exact task/canonical pair. Keep cleanup is bounded by the same
|
||||
pause guard as inactive-marker recovery so manual and unrelated pauses are never cleared.
|
||||
*/
|
||||
if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) {
|
||||
if (canClearInactiveMarker) {
|
||||
rmSync(promptPath, { force: true });
|
||||
await this.store.updateTask(task.id, {
|
||||
paused: false,
|
||||
pausedReason: null,
|
||||
status: null,
|
||||
sourceMetadataPatch: { nearDuplicateDismissed: true },
|
||||
});
|
||||
resolved += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (resolution === "delete") {
|
||||
await this.store.deleteTask(task.id, { removeLineageReferences: true, auditContext: { agentId: "self-healing", runId: generateSyntheticRunId("self-heal-explicit-duplicate", task.id) } });
|
||||
} else if (resolution === "prompt") {
|
||||
|
||||
@@ -2502,6 +2502,27 @@ export class TriageProcessor {
|
||||
}
|
||||
|
||||
const resolution = settings.triageDuplicateResolution ?? "prompt";
|
||||
/*
|
||||
FNXC:DuplicateIntake 2026-07-20-12:00:
|
||||
FN-8440 requires a Keep decision to survive marker re-ingestion during replan. The
|
||||
acknowledgement is scoped to this canonical id, so a marker targeting a different active
|
||||
task still receives its own prompt; user and unrelated pauses remain untouched.
|
||||
*/
|
||||
const keepAcknowledged = fusionCore.isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalId);
|
||||
if (resolution === "prompt" && keepAcknowledged) {
|
||||
if (canClearInactiveMarker) {
|
||||
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
|
||||
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
|
||||
})) return;
|
||||
await this.updatePlanningStateIfStillCurrent(task, {
|
||||
paused: false,
|
||||
pausedReason: null,
|
||||
status: null,
|
||||
sourceMetadataPatch: { nearDuplicateDismissed: true },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resolution === "delete") {
|
||||
const deleteTaskIf = (this.store as unknown as { deleteTaskIf?: TaskStore["deleteTaskIf"] }).deleteTaskIf;
|
||||
if (typeof deleteTaskIf !== "function") return;
|
||||
|
||||
Reference in New Issue
Block a user