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:
7
.changeset/fn-8356-stale-duplicate-decision.md
Normal file
7
.changeset/fn-8356-stale-duplicate-decision.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user