From e3fbd2ebf54c3803ee44bd3eb5e9dd0a04629065 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 1 Aug 2026 11:53:53 -0700 Subject: [PATCH] fix: stop inactive-duplicate clear from replan-storming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a DUPLICATE marker for an inactive or kept canonical left status:null without PROMPT.md. The scheduler treats planning→null as "finished planning" and re-dispatched the card; FS validation rebounded to needs-replan with no ceiling, and triage re-planned with empty feedback so the planner could re-emit the same inactive marker forever (FN-8704 / FN-8676). Leave needs-replan + dismissal metadata + replan feedback instead, share the clear contract across triage and self-healing, and bound scheduler filesystem-validation rebounds with the shared recovery budget. --- .../explicit-duplicate-marker-sweep.test.ts | 18 ++++- ...f-healing-stale-duplicate-decision.test.ts | 3 + .../triage-explicit-duplicate-marker.test.ts | 33 ++++++-- packages/engine/src/duplicate-marker-clear.ts | 47 +++++++++++ packages/engine/src/scheduler.ts | 40 ++++++++-- packages/engine/src/self-healing.ts | 74 +++++++++++++---- packages/engine/src/triage.ts | 80 ++++++++++++++----- 7 files changed, 247 insertions(+), 48 deletions(-) create mode 100644 packages/engine/src/duplicate-marker-clear.ts diff --git a/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts b/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts index bfe989bb22..5f3db3e444 100644 --- a/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts @@ -107,8 +107,18 @@ const canRun = hasGit && hasPg; const updated = await fx.store.getTask(duplicate.id); expect(updated.paused).not.toBe(true); expect(updated.pausedReason ?? null).toBeNull(); - expect(updated.status ?? null).toBeNull(); + // needs-replan (not null) so the card cannot look planning-finished without PROMPT + expect(updated.status).toBe("needs-replan"); + expect(updated.sourceMetadata).toEqual(expect.objectContaining({ + nearDuplicateOf: canonicalId, + nearDuplicateDismissed: true, + duplicateSource: "triage-marker", + })); expect(existsSync(promptPath)).toBe(false); + expect(updated.log?.some((entry) => + entry.action === "Duplicate marker cleared for re-specification" + && String(entry.outcome ?? "").includes(canonicalId), + )).toBe(true); }); it.each([ @@ -146,7 +156,11 @@ const canRun = hasGit && hasPg; 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(updated.status).toBe("needs-replan"); + expect(updated.sourceMetadata).toEqual(expect.objectContaining({ + nearDuplicateOf: canonical.id, + nearDuplicateDismissed: true, + })); expect(existsSync(promptPath)).toBe(false); }); diff --git a/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts b/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts index e18c728331..93da54afa4 100644 --- a/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts +++ b/packages/engine/src/__tests__/self-healing-stale-duplicate-decision.test.ts @@ -105,6 +105,8 @@ describe("FN-8356: reconcile stale duplicate-decision pauses", () => { const recovered = await store.getTask(id); expect(recovered?.paused).toBe(false); expect(recovered?.pausedReason).toBeNull(); + // needs-replan (not null) so the card cannot look planning-finished without a real PROMPT + expect(recovered?.status).toBe("needs-replan"); expect(recovered?.sourceMetadata?.nearDuplicateDismissed).toBe(true); // TaskCard and NotificationService both key their decision affordance on this predicate. expect(recovered?.pausedReason === "duplicate-decision-required").toBe(false); @@ -137,6 +139,7 @@ describe("FN-8356: reconcile stale duplicate-decision pauses", () => { const recovered = await store.getTask("FN-1"); expect(recovered?.paused).toBe(false); expect(recovered?.pausedReason).toBeNull(); + expect(recovered?.status).toBe("needs-replan"); }); /* diff --git a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts index 6eaa5d43cd..73f78d8ac0 100644 --- a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts +++ b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts @@ -94,7 +94,18 @@ describe("triage explicit duplicate marker short-circuit", () => { }); 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 })); + // Must leave needs-replan (not status:null) so the scheduler does not wake on a prompt-less card. + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + paused: false, + pausedReason: null, + status: "needs-replan", + sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", nearDuplicateDismissed: true }), + })); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-002", + "Duplicate marker cleared for re-specification", + expect.stringContaining("FN-001"), + ); }); it("does not re-pause a same-canonical Keep acknowledgement after marker reprocessing", async () => { @@ -113,7 +124,8 @@ describe("triage explicit duplicate marker short-circuit", () => { expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, - sourceMetadataPatch: { nearDuplicateDismissed: true }, + status: "needs-replan", + sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", nearDuplicateDismissed: true }), })); expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true })); }); @@ -165,11 +177,22 @@ describe("triage explicit duplicate marker short-circuit", () => { await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); - expect(store.updateTask).toHaveBeenCalledWith("FN-002", { + // needs-replan + dismissal + feedback — never status:null (FN-8704 replan storm) + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, - status: null, - }); + status: "needs-replan", + sourceMetadataPatch: expect.objectContaining({ + nearDuplicateOf: "FN-001", + nearDuplicateDismissed: true, + duplicateSource: "triage-marker", + }), + })); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-002", + "Duplicate marker cleared for re-specification", + expect.stringMatching(/FN-001.*do not re-emit/i), + ); expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true })); expect(store.deleteTask).not.toHaveBeenCalled(); }); diff --git a/packages/engine/src/duplicate-marker-clear.ts b/packages/engine/src/duplicate-marker-clear.ts new file mode 100644 index 0000000000..7795a0c619 --- /dev/null +++ b/packages/engine/src/duplicate-marker-clear.ts @@ -0,0 +1,47 @@ +/** + * Shared contracts for clearing an explicit DUPLICATE: marker without a real plan. + * + * FNXC:NearDuplicateDetection 2026-08-01-18:47: + * Clearing a DUPLICATE marker must leave needs-replan + durable feedback + + * nearDuplicateDismissed — never status:null. status:null is the planning-finished + * signal the scheduler wakes on, so a prompt-less null-status card re-dispatches, + * FS-fails, and storms (observed on FN-8704 / inactive FN-8676). + */ + +/** Log action picked up by triage's needs-replan feedback scanner. */ +export const TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION = "Duplicate marker cleared for re-specification"; + +export function buildInactiveDuplicateClearFeedback(canonicalId: string): string { + return `Explicit duplicate marker targeting ${canonicalId} was cleared because that task is missing, deleted, done, or archived. Write a full PROMPT.md for this work. Do not re-emit DUPLICATE: ${canonicalId}.`; +} + +export function buildKeepDuplicateClearFeedback(canonicalId: string): string { + return `Duplicate marker for ${canonicalId} was cleared (Keep / keep-acknowledged). Write a full PROMPT.md for this work. Do not re-emit DUPLICATE: ${canonicalId}.`; +} + +/** Patch applied when a marker is cleared so the card is unplanned, not "planning finished". */ +export function buildMarkerClearedReplanTaskPatch(canonicalId: string): { + paused: false; + pausedReason: null; + status: "needs-replan"; + error: null; + sourceMetadataPatch: { + nearDuplicateOf: string; + nearDuplicateScore: number; + duplicateSource: "triage-marker"; + nearDuplicateDismissed: true; + }; +} { + return { + paused: false, + pausedReason: null, + status: "needs-replan", + error: null, + sourceMetadataPatch: { + nearDuplicateOf: canonicalId, + nearDuplicateScore: 1, + duplicateSource: "triage-marker", + nearDuplicateDismissed: true, + }, + }; +} diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 4b1b13e568..70f26c0f9b 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -48,6 +48,7 @@ import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } fr import { moveTaskToReplanColumn } from "./replan-target.js"; import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js"; import { decideMissionSymbolAdmission, resolveMissionFeatureForTask } from "./mission-symbol-admission.js"; +import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./recovery-policy.js"; const SYMBOL_LOCK_LEASE_MS = 10 * 60_000; @@ -2339,12 +2340,41 @@ export class Scheduler { const validation = await this.validateTaskFilesystem(task.id); if (!validation.valid) { schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`); - // See the FNXC:WorkflowScheduling 2026-07-13-11:25 note in the legacy loop: the - // status write is what makes triage rediscover a card whose replan column equals - // its current column. + /* + FNXC:WorkflowScheduling 2026-08-01-18:47: + Missing/empty PROMPT used to rebound with unbounded needs-replan writes (FN-8704 + storm twin). Share the planning recovery budget: backoff while attempts remain, + then park failed so a broken task dir cannot spin forever. The status write is + still what makes triage rediscover a card whose replan column equals its current column. + */ const replanColumn = await moveTaskToReplanColumn(this.store, task); - await this.store.updateTask(task.id, { status: "needs-replan" }); - await this.store.logEntry(task.id, `Task rebounded to ${replanColumn} for re-specification — filesystem validation failed`, validation.reason); + const decision = computeRecoveryDecision({ + recoveryRetryCount: task.recoveryRetryCount, + nextRecoveryAt: task.nextRecoveryAt, + }); + if (!decision.shouldRetry) { + const error = `REQUIRED_ARTIFACT_RECOVERY_EXHAUSTED: filesystem validation failed (${validation.reason}) after ${MAX_RECOVERY_RETRIES} automatic planning retries.`; + await this.store.updateTask(task.id, { + status: "failed", + error, + recoveryRetryCount: null, + nextRecoveryAt: null, + }); + await this.store.logEntry(task.id, error, validation.reason); + return null; + } + const attempt = decision.nextState.recoveryRetryCount ?? MAX_RECOVERY_RETRIES; + await this.store.updateTask(task.id, { + status: "needs-replan", + error: null, + recoveryRetryCount: decision.nextState.recoveryRetryCount, + nextRecoveryAt: decision.nextState.nextRecoveryAt, + }); + await this.store.logEntry( + task.id, + `Task rebounded to ${replanColumn} for re-specification — filesystem validation failed (attempt ${attempt}/${MAX_RECOVERY_RETRIES} in ${formatDelay(decision.delayMs)})`, + validation.reason, + ); return null; } diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index e97c38f70b..b0ee72f3b6 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -40,6 +40,12 @@ import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PR import { finalizePlanningSegment } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; +import { + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + buildInactiveDuplicateClearFeedback, + buildKeepDuplicateClearFeedback, + buildMarkerClearedReplanTaskPatch, +} from "./duplicate-marker-clear.js"; import { mergeEffectiveSettings } from "./effective-settings.js"; import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, hasUsableWorktreeShape, isUsableTaskWorktree, relocateReclaimableWorktreeIntoRoot, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js"; import { @@ -7064,12 +7070,31 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonical); if (!isNearDuplicateCanonicalInactive(canonical ?? undefined, canonicalFlags)) continue; - await this.store.updateTask(task.id, { - paused: false, - pausedReason: null, - status: null, - sourceMetadataPatch: { nearDuplicateDismissed: true }, - }); + /* + FNXC:NearDuplicateDetection 2026-08-01-18:47: + Stale-decision recovery for an inactive canonical is the same writer as marker clear: + needs-replan (not status:null) plus dismissal so the card cannot look planning-finished + without a real PROMPT. Drop a still-present DUPLICATE marker file when present. + */ + const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"); + if (existsSync(promptPath)) { + try { + const written = readFileSync(promptPath, "utf-8"); + if (parseExplicitDuplicateMarker(written)) { + rmSync(promptPath, { force: true }); + } + } catch { + // best-effort marker removal; status write still proceeds + } + } + await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalId)); + if (typeof this.store.logEntry === "function") { + await Promise.resolve(this.store.logEntry( + task.id, + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + buildInactiveDuplicateClearFeedback(canonicalId), + )).catch(() => {}); + } await createRunAuditor(this.store, { runId: generateSyntheticRunId("reconcile-stale-duplicate-decision", task.id), agentId: "self-healing", @@ -14415,6 +14440,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane); FN-8356 keeps maintenance from re-parking a marker against a missing, deleted, done, or archived canonical. Such a decision has no detail-banner action, so cleanup restores eligible work to planning while preserving explicit, implicit, and unrelated system pauses. + + FNXC:NearDuplicateDetection 2026-08-01-18:47: + Mirror triage: marker clear leaves needs-replan + feedback + dismissal, never + status:null (FN-8704 replan storm when the scheduler wakes on planning→null without PROMPT). */ const canClearInactiveMarker = task.userPaused !== true && (task.paused !== true || task.pausedReason === "duplicate-decision-required") @@ -14423,7 +14452,15 @@ const movedTask = await this.store.moveTask(task.id, completeLane); if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) { if (canClearInactiveMarker) { rmSync(promptPath, { force: true }); - await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null }); + const patch = buildMarkerClearedReplanTaskPatch(marker.canonicalId); + await this.store.updateTask(task.id, patch); + if (typeof this.store.logEntry === "function") { + await Promise.resolve(this.store.logEntry( + task.id, + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + buildInactiveDuplicateClearFeedback(marker.canonicalId), + )).catch(() => {}); + } resolved += 1; } continue; @@ -14439,12 +14476,14 @@ const movedTask = await this.store.moveTask(task.id, completeLane); 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 }, - }); + await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id)); + if (typeof this.store.logEntry === "function") { + await Promise.resolve(this.store.logEntry( + task.id, + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + buildKeepDuplicateClearFeedback(canonicalTask.id), + )).catch(() => {}); + } resolved += 1; } continue; @@ -14456,7 +14495,14 @@ const movedTask = await this.store.moveTask(task.id, completeLane); await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null }); } else { rmSync(promptPath, { force: true }); - await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null, sourceMetadataPatch: { nearDuplicateOf: canonicalTask.id, nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: true } }); + await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id)); + if (typeof this.store.logEntry === "function") { + await Promise.resolve(this.store.logEntry( + task.id, + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + buildKeepDuplicateClearFeedback(canonicalTask.id), + )).catch(() => {}); + } } log.log(`[self-healing] resolved explicit duplicate marker ${task.id} → ${canonicalTask.id}`); resolved += 1; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index fbbd54f349..912b89d567 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -218,6 +218,12 @@ import { } from "./tool-availability.js"; import { runGhostBugPreflight } from "./triage-preflight.js"; import { archiveAsGhostBug } from "./self-healing.js"; +import { + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + buildInactiveDuplicateClearFeedback, + buildKeepDuplicateClearFeedback, + buildMarkerClearedReplanTaskPatch, +} from "./duplicate-marker-clear.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; import { resolveAndEmitGoalContext } from "./goal-injection-diagnostics.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; @@ -2907,6 +2913,7 @@ export class TriageProcessor { || entry.action === "User comment invalidated spec approval — task needs re-specification" || entry.action === "AI spec revision requested" || entry.action === TRIAGE_STUCK_RESUME_LOG_ACTION + || entry.action === TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION ); feedback = feedbackLogEntry?.outcome; @@ -4016,6 +4023,36 @@ export class TriageProcessor { return true; } + /* + FNXC:NearDuplicateDetection 2026-08-01-18:47: + Shared writer for every "delete the DUPLICATE marker and ask planning for a real plan" + exit. Must leave status:needs-replan (not null), durable replan feedback, and + nearDuplicateDismissed so (a) the scheduler's planning→null wake does not re-dispatch + a prompt-less card, and (b) the next planner is told not to re-emit the same id. + Outcome stays parked (default) — no Plan Review handoff until a real plan is written. + */ + private async clearDuplicateMarkerForReplan( + task: Task, + canonicalId: string, + feedback: string, + ): Promise { + if (!await this.runIfStillPlanningUnderTaskLock(task, async () => { + await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); + })) return false; + + try { + await Promise.resolve(this.store.logEntry(task.id, TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, feedback)); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + planLog.warn(`${task.id}: failed to log marker-clear replan feedback: ${msg}`); + } + + return await this.updatePlanningStateIfStillCurrent( + task, + buildMarkerClearedReplanTaskPatch(canonicalId), + ); + } + private async finalizeApprovedTaskBody( task: Task, writtenInput: string, @@ -4053,14 +4090,20 @@ export class TriageProcessor { view deliberately hides decisions for missing, deleted, done, or archived canonicals, so remove only the marker and return eligible work to planning instead of stranding its badge; explicit, implicit, and unrelated pauses are preserved. + + FNXC:NearDuplicateDetection 2026-08-01-18:47: + Clearing must leave needs-replan + feedback + dismissal — never status:null. A prompt-less + null status is the scheduler's "planning finished" wake signal and re-opens the FN-8704 + replan storm (schedule → missing PROMPT → needs-replan → re-emit inactive DUPLICATE). */ const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask); if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined, canonicalFlags)) { 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 }); + await this.clearDuplicateMarkerForReplan( + task, + canonicalId, + buildInactiveDuplicateClearFeedback(canonicalId), + ); } return; } @@ -4075,15 +4118,11 @@ export class TriageProcessor { 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 }, - }); + await this.clearDuplicateMarkerForReplan( + task, + canonicalId, + buildKeepDuplicateClearFeedback(canonicalId), + ); } return; } @@ -4114,15 +4153,12 @@ export class TriageProcessor { await this.store.recordActivity({ type: "task:auto-archived-duplicate", taskId: task.id, details: "Flagged (not deleted) as triage-marker duplicate", metadata: { canonicalTaskId: canonicalId, source: "triage-marker-flagged" } }); return; } - if (!await this.runIfStillPlanningUnderTaskLock(task, async () => { - await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); - })) return; - if (!await this.updatePlanningStateIfStillCurrent(task, { - paused: false, - pausedReason: null, - status: null, - sourceMetadataPatch: { nearDuplicateOf: canonicalId, nearDuplicateScore: 1, duplicateSource: "triage-marker", nearDuplicateDismissed: true }, - })) return; + // resolution === "keep" (and any other non-prompt/delete policy that drops the marker) + await this.clearDuplicateMarkerForReplan( + task, + canonicalId, + buildKeepDuplicateClearFeedback(canonicalId), + ); return; }