FN-8356: clear stale duplicate decision pauses

Clear inactive duplicate markers so eligible tasks resume planning instead of showing a stranded decision badge.

- Reconcile stale triage-marker duplicate pauses during self-healing and record audit events.
- Clear inactive canonical markers during triage while preserving user and unrelated pauses.
- Cover missing, deleted, completed, and archived canonical states with regression tests.

Files changed:
 .changeset/fn-8356-stale-duplicate-decision.md     |   7 ++
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |   1 +
 .../explicit-duplicate-marker-sweep.test.ts        |  43 ++++++--
 .../self-healing-stale-duplicate-decision.test.ts  | 109 +++++++++++++++++++++
 .../triage-explicit-duplicate-marker.test.ts       |  32 ++++--
 packages/engine/src/run-audit.ts                   |   2 +
 packages/engine/src/self-healing.ts                |  87 ++++++++++++++--
 packages/engine/src/triage.ts                      |  41 ++++++--
 9 files changed, 298 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-8356

Fusion-Task-Lineage: 8df8f0ee-d73e-41d6-8abe-a4b33662c9da

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 20:06:41 -07:00
parent d31a43d586
commit 0dbe67c851
9 changed files with 299 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix tasks stuck on "Needs your decision" when their duplicate is already done.
category: fix
dev: Adds the task:reconcile-stale-duplicate-decision self-healing audit event.

View File

@@ -281,6 +281,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- FN-8004: `agent:heartbeat-move-skipped-soft-delete` records a heartbeat move that races a soft-deleted task without parking the durable agent. Metadata remains ids/timestamps/source only (`agentId`, optional `taskId`/`deletedAt`, `moveAttemptedAt`, optional `source`); it never stores error prose.
- FN-8141: the executor's `fn_task_done(outcome="blocked", reason=..., blockedBy?=[...])` honest-blocked exit emits `task:execution-blocked-parked` when an executor parks a genuinely-impossible task `failed` (`error = "BLOCKED: <reason>"`) instead of laundering it to `done` by skipping steps. It bypasses the completion/verdict/bulk-completion gates (blocked is not a completion claim), leaves steps in their true statuses, preserves worktree/branch, records `blockedBy` as real `task.dependencies` edges so the task requeues behind the blocker, and does NOT hand off to review — the parked row is honored by the executor's `status === "failed"` post-loop branch and is not auto-recovered into in-review by `recoverStrandedCompletedTodoTasks` (steps are not all done/skipped and `task.error` is set). Metadata stays ids/outcomes-only (`taskId`, `blockedBy` ids, `hasReason` boolean — never the reason prose).
- FN-8305: durable symbol-lock operations emit `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`. Metadata is ids/counts/outcomes-only; normalized opaque symbol keys are permitted IDs, while raw symbol prose is not.
- FN-8356: self-healing emits `task:reconcile-stale-duplicate-decision` when it clears a triage-marker duplicate-decision pause against a missing, deleted, done, or archived canonical. Metadata is ids/outcomes-only (`taskId`, `canonicalId`, `canonicalColumn`, `canonicalDeleted`, `priorPausedReason`); active canonical decisions and user pauses remain untouched.
## Reference docs (deeper detail)

View File

@@ -1112,6 +1112,7 @@ The run-audit system records every mutation performed by the engine across four
- **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put).
- **Database / `task:reattach-orphaned-execution`** — emitted by `reattachOrphanedAssignedExecutions` (FN-6336) when self-healing re-dispatches an idle assigned `in-progress` task forward via `executor.resumeTaskForAgent(agentId)` after proving the assigned agent has no active heartbeat run or active execution.
- **Database / `task:reconcile-stale-agent-assignment`** — emitted when self-healing or heartbeat reconciliation clears stale durable `Agent.taskId`/`state` for a task parked in `todo`/`triage` without live execution proof. Metadata includes `{ agentId, taskId, taskColumn, agentState, status, blockedBy, overlapBlockedBy, hadFreshRun, hadActiveExecution, reason }`; task queue/lease fields are preserved.
- **Database / `task:reconcile-stale-duplicate-decision`** — emitted when self-healing clears a triage-marker duplicate-decision pause whose canonical is missing, deleted, done, or archived. Metadata is ids/outcomes-only: `{ taskId, canonicalId, canonicalColumn, canonicalDeleted, priorPausedReason }`; active canonicals and user pauses are excluded.
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution.
- **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list.

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
// FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005).
@@ -86,17 +87,47 @@ const canRun = hasGit && hasPg;
expect(untouched.status).toBe("failed");
});
it("leaves marker tasks alone when the canonical target is missing", async () => {
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "delete" } });
it.each(["missing", "done", "archived", "soft-deleted"] as const)("cleans an inactive %s canonical marker instead of parking a hidden decision", async (state) => {
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } });
fixtures.push(fx);
const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: "DUPLICATE: FN-9999\n" });
let canonicalId = "FN-9999";
if (state !== "missing") {
const canonical = await fx.store.createTask({ title: "Inactive canonical", description: "canonical", column: state === "soft-deleted" ? "triage" : state });
canonicalId = canonical.id;
if (state === "soft-deleted") {
await fx.store.deleteTask(canonical.id, { removeLineageReferences: true });
}
}
const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: duplicateStub(canonicalId) });
const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md");
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
expect((await fx.store.getTask(duplicate.id)).column).toBe("triage");
const activity = await fx.store.getActivityLog({ type: "task:auto-archived-duplicate", limit: 20 });
expect(activity.find((entry) => entry.taskId === duplicate.id)).toBeUndefined();
const updated = await fx.store.getTask(duplicate.id);
expect(updated.paused).not.toBe(true);
expect(updated.pausedReason ?? null).toBeNull();
expect(updated.status ?? null).toBeNull();
expect(existsSync(promptPath)).toBe(false);
});
it.each([
["user pause", { userPaused: true, paused: true, pausedReason: "manual" }],
["implicit user pause", { paused: true, pausedReason: null }],
["unrelated pause", { paused: true, pausedReason: "awaiting-approval" }],
])("preserves a %s while an inactive marker is encountered", async (_label, pause) => {
const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } });
fixtures.push(fx);
const duplicate = await createPromptTask(fx, { id: "FN-5301", column: "triage", prompt: duplicateStub("FN-9999") });
await fx.store.updateTask(duplicate.id, pause);
const promptPath = join(fx.rootDir, ".fusion", "tasks", duplicate.id, "PROMPT.md");
await (fx.manager as any).resolveExplicitDuplicateMarkerTasks();
const updated = await fx.store.getTask(duplicate.id);
expect(updated.paused).toBe(true);
expect(updated.pausedReason ?? null).toBe(pause.pausedReason ?? null);
expect(existsSync(promptPath)).toBe(true);
});
it("leaves full specs untouched", async () => {

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Settings, Task, TaskStore } from "@fusion/core";
const { recordRunAuditEventMock } = vi.hoisted(() => ({
recordRunAuditEventMock: vi.fn(async () => undefined),
}));
vi.mock("../run-audit.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../run-audit.js")>();
return {
...actual,
createRunAuditor: vi.fn(() => ({ database: recordRunAuditEventMock, git: vi.fn(), filesystem: vi.fn(), sandbox: vi.fn() })),
};
});
import { SelfHealingManager } from "../self-healing.js";
function task(id: string, overrides: Partial<Task> = {}): Task {
return {
id,
title: id,
description: id,
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
function stranded(id: string, canonicalId: string, overrides: Partial<Task> = {}): Task {
return task(id, {
paused: true,
pausedReason: "duplicate-decision-required",
sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: canonicalId },
...overrides,
});
}
function storeFor(tasks: Task[]): TaskStore & EventEmitter {
const tasksById = new Map(tasks.map((entry) => [entry.id, entry]));
return Object.assign(new EventEmitter(), {
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false } as Settings)),
listTasks: vi.fn(async () => [...tasksById.values()]),
getTask: vi.fn(async (id: string) => tasksById.get(id)),
updateTask: vi.fn(async (id: string, patch: Partial<Task> & { sourceMetadataPatch?: Record<string, unknown> }) => {
const current = tasksById.get(id)!;
const next = {
...current,
...patch,
sourceMetadata: patch.sourceMetadataPatch ? { ...current.sourceMetadata, ...patch.sourceMetadataPatch } : current.sourceMetadata,
} as Task;
tasksById.set(id, next);
return next;
}),
}) as unknown as TaskStore & EventEmitter;
}
describe("FN-8356: reconcile stale duplicate-decision pauses", () => {
beforeEach(() => vi.clearAllMocks());
it("clears the FN-8353-shaped hidden decision for every inactive canonical state and audits each recovery", async () => {
const done = task("FN-DONE", { column: "done" });
const archived = task("FN-ARCHIVED", { column: "archived" });
const deleted = task("FN-DELETED", { deletedAt: new Date().toISOString() });
const tasks = [
stranded("FN-1", done.id), done,
stranded("FN-2", archived.id), archived,
stranded("FN-3", deleted.id), deleted,
stranded("FN-4", "FN-MISSING"),
];
const store = storeFor(tasks);
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(4);
for (const id of ["FN-1", "FN-2", "FN-3", "FN-4"]) {
const recovered = await store.getTask(id);
expect(recovered?.paused).toBe(false);
expect(recovered?.pausedReason).toBeNull();
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);
}
expect(recordRunAuditEventMock).toHaveBeenCalledTimes(4);
expect(recordRunAuditEventMock).toHaveBeenCalledWith(expect.objectContaining({
type: "task:reconcile-stale-duplicate-decision",
metadata: expect.objectContaining({ priorPausedReason: "duplicate-decision-required" }),
}));
});
it("leaves active canonical decisions, user pauses, unrelated reasons, and non-marker sources untouched", async () => {
const active = task("FN-ACTIVE", { column: "todo" });
const activeDecision = stranded("FN-1", active.id);
const userPaused = stranded("FN-2", "FN-MISSING", { userPaused: true });
const unrelatedPause = stranded("FN-3", "FN-MISSING", { pausedReason: "awaiting-approval" });
const nonMarker = stranded("FN-4", "FN-MISSING", { sourceMetadata: { duplicateSource: "other", nearDuplicateOf: "FN-MISSING" } });
const store = storeFor([active, activeDecision, userPaused, unrelatedPause, nonMarker]);
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(0);
for (const entry of [activeDecision, userPaused, unrelatedPause, nonMarker]) {
expect(await store.getTask(entry.id)).toMatchObject({ paused: true, pausedReason: entry.pausedReason });
}
expect(recordRunAuditEventMock).not.toHaveBeenCalled();
});
});

View File

@@ -88,15 +88,35 @@ describe("triage explicit duplicate marker short-circuit", () => {
expect(store.deleteTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: false, pausedReason: null, status: null }));
});
it("does not short-circuit when the canonical target is missing", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(null),
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) });
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true);
expect(store.updateTask).toHaveBeenCalledWith("FN-002", {
paused: false,
pausedReason: null,
status: null,
});
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-999\n")).resolves.toBe(false);
expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true }));
expect(store.deleteTask).not.toHaveBeenCalled();
expect(store.recordActivity).not.toHaveBeenCalled();
});
it.each([
["user pause", createTask({ userPaused: true, paused: true, pausedReason: "manual" })],
["implicit user pause", createTask({ paused: true, pausedReason: null })],
["unrelated pause", createTask({ paused: true, pausedReason: "awaiting-approval" })],
])("preserves a %s while an inactive marker is encountered", async (_label, task) => {
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(null) });
await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
expect(store.updateTask).not.toHaveBeenCalled();
});
it("does not short-circuit on circular self-reference", async () => {

View File

@@ -555,6 +555,8 @@ export type DatabaseMutationType =
* Self-healing must leave file-scope lease queues intact while recording when stale durable Agent.taskId/state drift is cleared. Metadata: { agentId, taskId, taskColumn, agentState, status, blockedBy, overlapBlockedBy, hadFreshRun, hadActiveExecution, reason }.
*/
| "task:reconcile-stale-agent-assignment"
/** Metadata: { taskId, canonicalId, canonicalColumn, canonicalDeleted, priorPausedReason } */
| "task:reconcile-stale-duplicate-decision"
/**
* FNXC:MergeQueue 2026-07-15-10:05:
* Wedged single-flight merge reclaim. Metadata ids/outcomes-only:

View File

@@ -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, parseExplicitDuplicateMarker, flagTriageDuplicate, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, 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, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, 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";
@@ -1365,6 +1365,7 @@ export class SelfHealingManager {
// FN-5092: must run BEFORE any merger pickup path so the merger queue is
// not stalled by a leaked `status: "merging"` on an already-done task.
{ name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus().then(() => undefined) },
{ name: "reconcile-stale-duplicate-decision", fn: () => this.reconcileStaleDuplicateDecisionPause().then(() => undefined) },
{ name: "recover-already-merged-review", fn: () => this.recoverAlreadyMergedReviewTasks().then(() => undefined) },
{ name: "recover-post-done-noncontinuable-wedge", fn: () => this.recoverPostDoneNonContinuableWedge().then(() => undefined) },
{ name: "recover-completion-handoff-limbo", fn: () => this.recoverCompletionHandoffLimbo().then(() => undefined) },
@@ -2659,6 +2660,7 @@ export class SelfHealingManager {
{ name: "finalize-noop-review", fn: () => this.finalizeNoOpReviewTasks() },
{ name: "reconcile-done-task-integrity", fn: () => this.reconcileDoneTaskIntegrity() },
{ name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() },
{ name: "reconcile-stale-duplicate-decision", fn: () => this.reconcileStaleDuplicateDecisionPause() },
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
// FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode reconcilers.
{ name: "reconcile-workspace-partial-lands", fn: () => this.reconcileWorkspacePartialLands() },
@@ -6338,6 +6340,65 @@ export class SelfHealingManager {
}
}
/**
* FNXC:NearDuplicateDetection 2026-07-17-20:10:
* FN-8356 reconciles only triage-marker duplicate-decision pauses whose canonical is no longer
* actionable. Its audit payload is ids/outcomes-only so stale-decision recovery never stores
* prompt or decision prose; active canonicals and user-owned pauses remain untouched.
*/
async reconcileStaleDuplicateDecisionPause(): Promise<number> {
try {
const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 });
const candidates = tasks.filter((task) =>
task.paused === true
&& task.userPaused !== true
&& task.pausedReason === "duplicate-decision-required"
&& task.sourceMetadata?.duplicateSource === "triage-marker"
&& typeof task.sourceMetadata?.nearDuplicateOf === "string",
);
let cleared = 0;
for (const task of candidates.slice(0, 50)) {
try {
const canonicalId = task.sourceMetadata!.nearDuplicateOf as string;
const canonical = await this.store.getTask(canonicalId).catch(() => null);
if (!isNearDuplicateCanonicalInactive(canonical ?? undefined)) continue;
await this.store.updateTask(task.id, {
paused: false,
pausedReason: null,
status: null,
sourceMetadataPatch: { nearDuplicateDismissed: true },
});
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("reconcile-stale-duplicate-decision", task.id),
agentId: "self-healing",
taskId: task.id,
taskLineageId: task.lineageId,
phase: "reconcile-stale-duplicate-decision",
}).database({
type: "task:reconcile-stale-duplicate-decision" as DatabaseMutationType,
target: task.id,
metadata: {
taskId: task.id,
canonicalId,
canonicalColumn: canonical?.column ?? null,
canonicalDeleted: Boolean(canonical?.deletedAt),
priorPausedReason: "duplicate-decision-required",
},
});
cleared += 1;
} catch (error) {
log.warn(`reconcileStaleDuplicateDecisionPause: failed for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return cleared;
} catch (error) {
log.error(`reconcileStaleDuplicateDecisionPause failed: ${error instanceof Error ? error.message : String(error)}`);
return 0;
}
}
/**
* Backward lifecycle move gated on triple proof (FN-5335).
* When the unproven fallback predicate fails, emits `task:finalize-no-op-review-no-action` and skips lifecycle mutation.
@@ -11718,11 +11779,25 @@ export class SelfHealingManager {
processedMarkers += 1;
const canonicalTask = await this.store.getTask(marker.canonicalId).catch(() => null);
if (
!canonicalTask ||
canonicalTask.deletedAt ||
canonicalTask.id.toLowerCase() === task.id.toLowerCase()
) {
if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) {
continue;
}
/*
FNXC:NearDuplicateDetection 2026-07-17-20:10:
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.
*/
const canClearInactiveMarker = task.userPaused !== true
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
&& (task.pausedReason == null || task.pausedReason === "duplicate-decision-required");
if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask)) {
if (canClearInactiveMarker) {
rmSync(promptPath, { force: true });
await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null });
resolved += 1;
}
continue;
}

View File

@@ -2867,16 +2867,23 @@ export class TriageProcessor {
}
const canonicalId = explicitDuplicateMarker.canonicalId;
const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
if (
!canonicalTask ||
canonicalTask.deletedAt ||
canonicalTask.id.toLowerCase() === task.id.toLowerCase()
) {
// A transient lookup failure must still fail open; only a genuine missing row is inactive.
const canonicalTask = await this.store.getTask(canonicalId);
if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) {
return false;
}
planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`);
/*
FNXC:NearDuplicateDetection 2026-07-17-20:10:
FN-8356 requires missing, deleted, done, and archived duplicate canonicals to flow through
marker cleanup instead of being rejected here. The detail banner cannot offer a decision for
an inactive canonical, so parking the card would strand its Needs your decision badge.
*/
if (isNearDuplicateCanonicalInactive(canonicalTask)) {
planLog.log(`${task.id} explicit duplicate marker targets inactive ${canonicalId}; clearing marker for replanning`);
} else {
planLog.log(`${task.id} explicit duplicate marker detected — redirecting to ${canonicalId}`);
}
await this.finalizeApprovedTask(task, written, settings, options);
return true;
} catch (err) {
@@ -2931,6 +2938,26 @@ export class TriageProcessor {
*/
if (explicitDuplicateMarker) {
const canonicalId = explicitDuplicateMarker.canonicalId;
const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
const canClearInactiveMarker = task.userPaused !== true
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
&& (task.pausedReason == null || task.pausedReason === "duplicate-decision-required");
/*
FNXC:NearDuplicateDetection 2026-07-17-20:10:
FN-8356 prevents an inactive duplicate canonical from creating a prompt pause. The detail
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.
*/
if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined)) {
if (canClearInactiveMarker) {
await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
await this.store.updateTask(task.id, { paused: false, pausedReason: null, status: null });
}
return;
}
const resolution = settings.triageDuplicateResolution ?? "prompt";
if (resolution === "delete") {
await this.store.recordActivity({