From f36e23848f2e634fbab9a0b31cadabc97164af12 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 8 Aug 2026 19:58:43 -0700 Subject: [PATCH] FN-8840: recognize duplicate redirects in task titles Recognize exact duplicate redirects in either task title or PROMPT.md while preserving conflicting decisions for operator resolution. - Resolve duplicate markers consistently across intake, scheduling, execution, dashboard, and replication paths. - Accept task-ID prefixes beyond FN- and expose duplicate-marker state to the UI. - Fail closed during stale-decision recovery when prompt, title, or persisted canonical redirects disagree. - Add regression coverage and operator documentation for title-based redirects. Files changed: .changeset/fn-8840-duplicate-title-redirect.md | 7 ++ docs/settings-reference.md | 2 +- docs/task-management.md | 11 +- .../__tests__/explicit-duplicate-marker.test.ts | 30 ++++- .../src/__tests__/mesh-task-replication.test.ts | 6 + .../src/duplicates/explicit-duplicate-marker.ts | 66 ++++++++--- packages/core/src/index.gate.ts | 3 + packages/core/src/index.ts | 3 + packages/core/src/mesh/mesh-task-replication.ts | 2 +- ...-task-workflow-routes.awaiting-planning.test.ts | 78 +++++++++++-- .../src/routes/register-task-workflow-routes.ts | 26 ++++- .../executor-explicit-duplicate-recovery.test.ts | 75 ++++++++++++ .../__tests__/merged-intake-hold-column.test.ts | 19 +++ .../scheduler-explicit-duplicate-marker.test.ts | 130 +++++++++++++++++++++ .../self-healing-stale-duplicate-decision.test.ts | 67 +++++++++++ .../triage-explicit-duplicate-marker.test.ts | 51 ++++++++ packages/engine/src/execution/hold-release.ts | 10 +- packages/engine/src/executor.ts | 11 +- packages/engine/src/scheduler.ts | 15 ++- packages/engine/src/self-healing.ts | 55 ++++++--- packages/engine/src/triage.ts | 98 +++++++++++++--- 21 files changed, 687 insertions(+), 78 deletions(-) Fusion-Task-Id: FN-8840 Fusion-Task-Lineage: d08a3e84-1851-4fba-bfa9-507116ad6219 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8840-duplicate-title-redirect.md | 7 + docs/settings-reference.md | 2 +- docs/task-management.md | 11 +- .../explicit-duplicate-marker.test.ts | 30 +++- .../__tests__/mesh-task-replication.test.ts | 6 + .../duplicates/explicit-duplicate-marker.ts | 66 ++++++--- packages/core/src/index.gate.ts | 3 + packages/core/src/index.ts | 3 + .../core/src/mesh/mesh-task-replication.ts | 2 +- ...-workflow-routes.awaiting-planning.test.ts | 78 ++++++++++- .../routes/register-task-workflow-routes.ts | 26 +++- ...ecutor-explicit-duplicate-recovery.test.ts | 75 ++++++++++ .../merged-intake-hold-column.test.ts | 19 +++ ...cheduler-explicit-duplicate-marker.test.ts | 130 ++++++++++++++++++ ...f-healing-stale-duplicate-decision.test.ts | 67 +++++++++ .../triage-explicit-duplicate-marker.test.ts | 51 +++++++ packages/engine/src/execution/hold-release.ts | 10 +- packages/engine/src/executor.ts | 11 +- packages/engine/src/scheduler.ts | 15 +- packages/engine/src/self-healing.ts | 55 +++++--- packages/engine/src/triage.ts | 98 +++++++++++-- 21 files changed, 687 insertions(+), 78 deletions(-) create mode 100644 .changeset/fn-8840-duplicate-title-redirect.md create mode 100644 packages/engine/src/__tests__/executor-explicit-duplicate-recovery.test.ts create mode 100644 packages/engine/src/__tests__/scheduler-explicit-duplicate-marker.test.ts diff --git a/.changeset/fn-8840-duplicate-title-redirect.md b/.changeset/fn-8840-duplicate-title-redirect.md new file mode 100644 index 0000000000..8a592477c8 --- /dev/null +++ b/.changeset/fn-8840-duplicate-title-redirect.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Recognize duplicate redirects in task titles and with custom task prefixes. +category: fix +dev: Uses the shared strict duplicate-marker parser across decision, admission, and recovery surfaces. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ea46bebe78..28332b4482 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1864,7 +1864,7 @@ One terminal `tool_error` after the current execution-run cursor therefore quali Choose the alternate model with the standard provider-aware selector in **Settings → Models · Project**; clearing it removes both persisted pair keys, and incomplete legacy pairs display as unset. **Settings → Scheduling** retains the enable toggle, optional node target, and retry policy. Escalation is enabled only when the toggle is true and either a complete provider/model pair or a node ID is configured. It is single-shot: after FN-7996 exhausts same-model retries, Fusion persists the override and tries once before the existing terminal park. The alternate model enters the [model-selection hierarchy](#model-selection-hierarchy) as a task-level override; a node target enters `resolveEffectiveNode` as a task-level routing override and is requeued so scheduler routing is recalculated. This remains opt-in by default to avoid unexpected model cost or execution behavior. Column-agent overrides still govern their sessions and can supersede a task-level model target. -| `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls `DUPLICATE: FN-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task for an operator Keep/Delete decision; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. | +| `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls exact `DUPLICATE: PREFIX-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task for an operator Keep/Delete decision; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. | ### `mobileNavPrimaryItems` diff --git a/docs/task-management.md b/docs/task-management.md index 4dbdaf0542..6ee48294fd 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -115,15 +115,16 @@ Archiving a workspace (multi-repository) task now synchronously removes every re Fusion also recognizes the canonical one-line redirect marker: - `DUPLICATE: FN-1234` -- `` `DUPLICATE: FN-1234` `` -- `**DUPLICATE: FN-1234**` +- `DUPLICATE: KB-1234` +- `` `DUPLICATE: KB-1234` `` +- `**DUPLICATE: KB-1234**` - fenced single-line wrappers such as: ```text DUPLICATE: FN-1234 ``` -The shared parser lives in `packages/core/src/explicit-duplicate-marker.ts` (`parseExplicitDuplicateMarker`). It is intentionally strict: after trimming outer whitespace and one optional wrapper layer, the content must reduce to exactly one substantive line matching `^DUPLICATE:\s*FN-\d+$`. Any extra prose, multiple markers, or full PROMPT bodies that merely mention duplicate text are ignored. +The shared parser lives in `packages/core/src/duplicates/explicit-duplicate-marker.ts` (`parseExplicitDuplicateMarker`). It is intentionally strict: after trimming outer whitespace and one optional wrapper layer, the content must reduce to exactly one substantive line matching `^DUPLICATE:\s*[A-Z]+-\d+$`. Exact markers are recognized in either `PROMPT.md` or the task title; a prompt marker wins only when both sources name the same canonical ID. Conflicting exact title/prompt markers fail closed for operator or planning correction. Any extra prose, multiple markers, malformed IDs, or full PROMPT bodies that merely mention duplicate text are ignored. This guard adds three fail-open layers on top of the existing duplicate stack, in final order: @@ -135,10 +136,10 @@ This guard adds three fail-open layers on top of the existing duplicate stack, i Layer behavior: - **Dashboard intake (`POST /api/tasks`)** — after deterministic/similarity/near-duplicate checks and before `createTask`, intake returns `409 duplicate_candidates` with `reason: "explicit-marker"` when the combined title/description is exactly a canonical redirect and the canonical target exists. `acknowledgedDuplicates` and `bypassDuplicateCheck: true` both suppress the conflict. Because this guard runs before task creation, the activity breadcrumb is attached to the canonical target. -- **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. +- **Triage planning loop** — before triage starts a planner session, an exact redirect in the prompt or title 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. +An operator's decision is durable for a task and its active canonical pair. **Keep** records the acknowledgement, clears the exact redirect source 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. diff --git a/packages/core/src/__tests__/explicit-duplicate-marker.test.ts b/packages/core/src/__tests__/explicit-duplicate-marker.test.ts index f24aa784e0..b37d378677 100644 --- a/packages/core/src/__tests__/explicit-duplicate-marker.test.ts +++ b/packages/core/src/__tests__/explicit-duplicate-marker.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseExplicitDuplicateMarker, + resolveExplicitDuplicateMarker, parseDuplicateMarkerFromSessionText, isDuplicateRedirectOnlyPrompt, nonExecutableDuplicateRedirectReason, @@ -69,13 +70,32 @@ describe("parseExplicitDuplicateMarker", () => { expect(parseExplicitDuplicateMarker(" ")).toBeNull(); }); - it("rejects non-FN identifiers", () => { - expect(parseExplicitDuplicateMarker("DUPLICATE: NOT-1234")).toBeNull(); + it("accepts custom task prefixes and normalizes them", () => { + expect(parseExplicitDuplicateMarker("DUPLICATE: kb-1234")).toEqual({ canonicalId: "KB-1234" }); }); - it("flags duplicate-only content as non-executable for dispatch", () => { + it("rejects malformed identifiers", () => { + expect(parseExplicitDuplicateMarker("DUPLICATE: KB-ABC")).toBeNull(); + expect(parseExplicitDuplicateMarker("DUPLICATE: KB_1234")).toBeNull(); + }); + + it("resolves exact prompt/title markers with deterministic source and conflicts", () => { + expect(resolveExplicitDuplicateMarker("DUPLICATE: kb-123", "DUPLICATE: KB-123")).toEqual({ + marker: { canonicalId: "KB-123" }, source: "prompt", conflict: false, + }); + expect(resolveExplicitDuplicateMarker(FULL_PROMPT, "DUPLICATE: KB-123")).toEqual({ + marker: { canonicalId: "KB-123" }, source: "title", conflict: false, + }); + expect(resolveExplicitDuplicateMarker("DUPLICATE: KB-123", "DUPLICATE: FN-123")).toEqual({ + marker: null, source: null, conflict: true, + }); + }); + + it("flags duplicate-only prompt or title content as non-executable for dispatch", () => { expect(isDuplicateRedirectOnlyPrompt("DUPLICATE: FN-8676")).toBe(true); + expect(isDuplicateRedirectOnlyPrompt(FULL_PROMPT, "DUPLICATE: KB-8676")).toBe(true); expect(nonExecutableDuplicateRedirectReason("DUPLICATE: FN-8676")).toContain("FN-8676"); + expect(nonExecutableDuplicateRedirectReason(FULL_PROMPT, "DUPLICATE: KB-8676")).toContain("task title"); expect(isDuplicateRedirectOnlyPrompt(FULL_PROMPT)).toBe(false); expect(nonExecutableDuplicateRedirectReason(FULL_PROMPT)).toBeNull(); }); @@ -113,9 +133,9 @@ describe("parseDuplicateMarkerFromSessionText", () => { expect(parseDuplicateMarkerFromSessionText(reply)).toEqual({ canonicalId: "FN-1" }); }); - it("tolerates backtick and bold wrappers and lowercase, like the file parser", () => { + it("tolerates wrappers, lowercase, and custom prefixes like the file parser", () => { expect(parseDuplicateMarkerFromSessionText("`duplicate: fn-7`")).toEqual({ canonicalId: "FN-7" }); - expect(parseDuplicateMarkerFromSessionText("**DUPLICATE: FN-8**")).toEqual({ canonicalId: "FN-8" }); + expect(parseDuplicateMarkerFromSessionText("**DUPLICATE: KB-8**")).toEqual({ canonicalId: "KB-8" }); }); it("returns null for empty or marker-free text", () => { diff --git a/packages/core/src/__tests__/mesh-task-replication.test.ts b/packages/core/src/__tests__/mesh-task-replication.test.ts index 56a8d5786a..4002db59ff 100644 --- a/packages/core/src/__tests__/mesh-task-replication.test.ts +++ b/packages/core/src/__tests__/mesh-task-replication.test.ts @@ -124,6 +124,12 @@ describe("isTaskAwaitingPlanning", () => { expect(isTaskAwaitingPlanning(task(), "# FN-1: Title\n\n## Steps\n\n1. Do it\n")).toBe(false); }); + it("keeps title redirects awaiting planning while incidental title prose remains executable", () => { + const plan = "# FN-1: Title\n\n## Steps\n\n1. Do it\n"; + expect(isTaskAwaitingPlanning(task({ title: "DUPLICATE: KB-123" }), plan)).toBe(true); + expect(isTaskAwaitingPlanning(task({ title: "Discuss DUPLICATE: KB-123" }), plan)).toBe(false); + }); + it("ignores statuses that are not planning parks", () => { for (const status of [undefined, null, "planning", "executing", "failed"]) { expect( diff --git a/packages/core/src/duplicates/explicit-duplicate-marker.ts b/packages/core/src/duplicates/explicit-duplicate-marker.ts index b7aa441e0c..5a3bb6754e 100644 --- a/packages/core/src/duplicates/explicit-duplicate-marker.ts +++ b/packages/core/src/duplicates/explicit-duplicate-marker.ts @@ -2,6 +2,16 @@ export interface ExplicitDuplicateMarker { canonicalId: string; } +export type ExplicitDuplicateMarkerSource = "prompt" | "title"; + +export interface ExplicitDuplicateMarkerResolution { + marker: ExplicitDuplicateMarker | null; + source: ExplicitDuplicateMarkerSource | null; + conflict: boolean; +} + +const DUPLICATE_MARKER_PATTERN = /^DUPLICATE:\s*([A-Z]+-\d+)\s*$/i; + function stripCodeFenceLayer(content: string): string { const fenceMatch = content.match(/^```(?:[\t ]*(?:text|markdown))?[\t ]*\n([\s\S]*?)\n```$/i); if (!fenceMatch) { @@ -41,7 +51,7 @@ export function parseExplicitDuplicateMarker(content: string): ExplicitDuplicate } const candidate = stripSingleWrapper(nonBlankLines[0] ?? ""); - const match = candidate.match(/^DUPLICATE:\s*(FN-\d+)\s*$/i); + const match = candidate.match(DUPLICATE_MARKER_PATTERN); if (!match) { return null; } @@ -52,26 +62,44 @@ export function parseExplicitDuplicateMarker(content: string): ExplicitDuplicate } /* -FNXC:DuplicateIntake 2026-08-01-19:24: -A PROMPT.md whose entire body is `DUPLICATE: FN-####` is a triage redirect verdict, not an -executable plan. FN-8704 was admitted into WIP because filesystem validation only required -non-empty content; the graph then failed at the `parse` node and parked failed in-progress in a -loop. Shared predicate so dispatch, hold-release, awaiting-planning badges, and graph-failure -recovery all reject this shape the same way. +FNXC:DuplicateIntake 2026-08-09-01:02: +FN-8840 requires every duplicate-decision and admission surface to evaluate the exact marker in +both PROMPT.md and the task title. Prompt wins only when both sources name the same canonical ID; +conflicting redirects fail closed so Fusion never silently chooses an operator's target. */ -/** True when content is solely an explicit triage duplicate redirect (not a real plan body). */ -export function isDuplicateRedirectOnlyPrompt(content: string): boolean { - return parseExplicitDuplicateMarker(content) !== null; +export function resolveExplicitDuplicateMarker( + promptContent: string | null | undefined, + title: string | null | undefined, +): ExplicitDuplicateMarkerResolution { + const promptMarker = typeof promptContent === "string" ? parseExplicitDuplicateMarker(promptContent) : null; + const titleMarker = typeof title === "string" ? parseExplicitDuplicateMarker(title) : null; + + if (promptMarker && titleMarker && promptMarker.canonicalId !== titleMarker.canonicalId) { + return { marker: null, source: null, conflict: true }; + } + if (promptMarker) return { marker: promptMarker, source: "prompt", conflict: false }; + if (titleMarker) return { marker: titleMarker, source: "title", conflict: false }; + return { marker: null, source: null, conflict: false }; } -/** - * Operator-facing reason for rejecting a non-executable PROMPT at dispatch, or null when content - * is not a duplicate-only redirect. - */ -export function nonExecutableDuplicateRedirectReason(content: string): string | null { - const marker = parseExplicitDuplicateMarker(content); - if (!marker) return null; - return `PROMPT.md is a duplicate redirect marker (DUPLICATE: ${marker.canonicalId}), not an executable plan`; +/** True when the exact prompt/title contract blocks execution pending duplicate resolution. */ +export function isDuplicateRedirectOnlyPrompt(content: string | null | undefined, title?: string | null): boolean { + const resolution = resolveExplicitDuplicateMarker(content, title); + return resolution.marker !== null || resolution.conflict; +} + +/** Operator-facing dispatch refusal reason, preserving the source that declared the redirect. */ +export function nonExecutableDuplicateRedirectReason( + content: string | null | undefined, + title?: string | null, +): string | null { + const resolution = resolveExplicitDuplicateMarker(content, title); + if (resolution.conflict) { + return "PROMPT.md and task title declare conflicting duplicate redirects; resolve the conflict before execution"; + } + if (!resolution.marker || !resolution.source) return null; + const source = resolution.source === "prompt" ? "PROMPT.md" : "task title"; + return `${source} is a duplicate redirect marker (DUPLICATE: ${resolution.marker.canonicalId}), not an executable plan`; } /* @@ -95,7 +123,7 @@ export function parseDuplicateMarkerFromSessionText(text: string): ExplicitDupli for (const rawLine of text.split("\n")) { const candidate = stripSingleWrapper(rawLine.trim()); - const match = candidate.match(/^DUPLICATE:\s*(FN-\d+)\s*$/i); + const match = candidate.match(DUPLICATE_MARKER_PATTERN); if (match) { return { canonicalId: match[1].toUpperCase() }; } diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index a27b2e3d96..7e992675f5 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -749,9 +749,12 @@ export { getTaskDuplicateLineage } from "./duplicates/duplicate-lineage.js"; export { parseDuplicateMarkerFromSessionText, parseExplicitDuplicateMarker, + resolveExplicitDuplicateMarker, isDuplicateRedirectOnlyPrompt, nonExecutableDuplicateRedirectReason, type ExplicitDuplicateMarker, + type ExplicitDuplicateMarkerResolution, + type ExplicitDuplicateMarkerSource, } from "./duplicates/explicit-duplicate-marker.js"; export { parseNoOpCompletionMarker, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0ff8c541e..7c53462b98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -871,7 +871,10 @@ export { getTaskDuplicateLineage } from "./duplicates/duplicate-lineage.js"; export { parseDuplicateMarkerFromSessionText, parseExplicitDuplicateMarker, + resolveExplicitDuplicateMarker, type ExplicitDuplicateMarker, + type ExplicitDuplicateMarkerResolution, + type ExplicitDuplicateMarkerSource, isDuplicateRedirectOnlyPrompt, nonExecutableDuplicateRedirectReason, } from "./duplicates/explicit-duplicate-marker.js"; diff --git a/packages/core/src/mesh/mesh-task-replication.ts b/packages/core/src/mesh/mesh-task-replication.ts index 344fcf7e23..6026d930ab 100644 --- a/packages/core/src/mesh/mesh-task-replication.ts +++ b/packages/core/src/mesh/mesh-task-replication.ts @@ -109,6 +109,6 @@ export function isTaskAwaitingPlanning( A duplicate-only PROMPT is unplanned for execution — badge and triage must agree with scheduler filesystem validation so the card shows "Queued to plan", not Ready. */ - if (isDuplicateRedirectOnlyPrompt(promptContent)) return true; + if (isDuplicateRedirectOnlyPrompt(promptContent, task.title)) return true; return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description); } diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts index 6fffa7f0e4..a31b95892f 100644 --- a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.awaiting-planning.test.ts @@ -22,7 +22,7 @@ Surface enumeration (the invariant, not just the reported repro): import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import express from "express"; -import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { TaskStore, Task } from "@fusion/core"; @@ -52,7 +52,7 @@ function makeTask(overrides: Partial = {}): Task { /** Write PROMPT.md for a task; omit `content` to leave the file missing. */ async function seedTaskDir(taskId: string, content?: string): Promise { - const dir = join(tasksRoot, taskId); + const dir = join(tasksRoot, ".fusion", "tasks", taskId); await mkdir(dir, { recursive: true }); if (content !== undefined) await writeFile(join(dir, "PROMPT.md"), content); } @@ -76,10 +76,21 @@ const RENAMED_HOLD_IR = { function createHarness(tasks: Task[], workflowIrs?: unknown[]) { const store: TaskStore = { - getRootDir: vi.fn(() => process.cwd()), + getRootDir: vi.fn(() => tasksRoot), getProjectScopedPluginMcpServers: vi.fn(async () => []), - getTaskDir: vi.fn((id: string) => join(tasksRoot, id)), + getTaskDir: vi.fn((id: string) => join(tasksRoot, ".fusion", "tasks", id)), getSettingsFast: vi.fn(async () => ({})), + getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null), + updateTask: vi.fn(async (id: string, updates: Record) => { + const task = tasks.find((candidate) => candidate.id === id); + if (!task) throw new Error("Task not found"); + const { sourceMetadataPatch, ...directUpdates } = updates; + Object.assign(task, directUpdates); + if (sourceMetadataPatch && typeof sourceMetadataPatch === "object") { + task.sourceMetadata = { ...task.sourceMetadata, ...sourceMetadataPatch }; + } + return task; + }), listTasks: vi.fn(async () => tasks), ...(workflowIrs ? { listWorkflowDefinitions: vi.fn(async () => workflowIrs.map((ir) => ({ ir }))) } : {}), } as unknown as TaskStore; @@ -153,7 +164,7 @@ describe("GET /tasks awaitingPlanning enrichment", () => { // A directory where the file should be: EISDIR, not ENOENT. That is not evidence either way, so // the client must fall back instead of being handed a fabricated label. const task = makeTask({ id: "FN-EISDIR" }); - await mkdir(join(tasksRoot, "FN-EISDIR", "PROMPT.md"), { recursive: true }); + await mkdir(join(tasksRoot, ".fusion", "tasks", "FN-EISDIR", "PROMPT.md"), { recursive: true }); const [row] = await fetchTasks([task]); @@ -204,9 +215,9 @@ describe("GET /tasks awaitingPlanning enrichment", () => { await seedTaskDir("FN-RENAMED", REAL_SPEC); const store = { - getRootDir: vi.fn(() => process.cwd()), + getRootDir: vi.fn(() => tasksRoot), getProjectScopedPluginMcpServers: vi.fn(async () => []), - getTaskDir: vi.fn((id: string) => join(tasksRoot, id)), + getTaskDir: vi.fn((id: string) => join(tasksRoot, ".fusion", "tasks", id)), getSettingsFast: vi.fn(async () => ({})), listTasks: vi.fn(async () => [task]), listWorkflowDefinitions: vi.fn(async () => [{ @@ -229,6 +240,59 @@ describe("GET /tasks awaitingPlanning enrichment", () => { expect((store as unknown as { listWorkflowDefinitions: { mock: { calls: unknown[] } } }).listWorkflowDefinitions.mock.calls).toHaveLength(1); }); + /* + FNXC:DuplicateIntake 2026-08-09-02:29: + A title-only redirect reaches the same Keep endpoint as a PROMPT.md redirect. The endpoint must + clear the exact title marker and preserve the full prompt, or the next triage pass re-flags the + dismissed duplicate after operator-authored work was destroyed. + */ + it("keeps a title-only redirect without deleting its executable prompt", async () => { + const task = makeTask({ + title: "DUPLICATE: KB-123", + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: "KB-123" }, + }); + await seedTaskDir(task.id, REAL_SPEC); + const { app } = createHarness([task]); + + const res = await REQUEST( + app, + "PATCH", + `/api/tasks/${task.id}`, + JSON.stringify({ dismissNearDuplicate: true }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(task.title).toBe("Duplicate redirect cleared: KB-123"); + expect(task.sourceMetadata).toMatchObject({ nearDuplicateDismissed: true }); + await expect(readFile(join(tasksRoot, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8")).resolves.toBe(REAL_SPEC); + }); + + it("cleans both matching sources when keeping a dual-source redirect", async () => { + const task = makeTask({ + title: "DUPLICATE: KB-123", + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: "KB-123" }, + }); + await seedTaskDir(task.id, "DUPLICATE: KB-123\n"); + const { app } = createHarness([task]); + + const res = await REQUEST( + app, + "PATCH", + `/api/tasks/${task.id}`, + JSON.stringify({ dismissNearDuplicate: true }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(task.title).toBe("Duplicate redirect cleared: KB-123"); + await expect(readFile(join(tasksRoot, ".fusion", "tasks", task.id, "PROMPT.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("still returns the board when the enrichment cannot resolve task directories", async () => { // Best-effort contract: a store without getTaskDir must not fail the board load. const task = makeTask({ id: "FN-NODIR" }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 1534d00aae..184e296cf5 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -60,6 +60,7 @@ import { resolveNearDuplicateCanonicalFlags, isEphemeralAgent, parseExplicitDuplicateMarker, + resolveExplicitDuplicateMarker, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReviewColumns, @@ -6193,9 +6194,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const existingTaskForDuplicateDismissal = dismissNearDuplicate === true ? await scopedStore.getTask(req.params.id) : null; + let duplicateDismissalResolution: ReturnType | null = null; if (dismissNearDuplicate === true) { const isTriageMarkerDecision = existingTaskForDuplicateDismissal?.sourceMetadata?.duplicateSource === "triage-marker" && existingTaskForDuplicateDismissal.pausedReason === "duplicate-decision-required"; + const existingPrompt = existingTaskForDuplicateDismissal + ? await readFile(join(scopedStore.getRootDir(), ".fusion", "tasks", existingTaskForDuplicateDismissal.id, "PROMPT.md"), "utf-8").catch(() => null) + : null; + duplicateDismissalResolution = resolveExplicitDuplicateMarker(existingPrompt, existingTaskForDuplicateDismissal?.title); + /* + * FNXC:DuplicateIntake 2026-08-09-02:29: + * FN-8840 extends an explicit redirect to task titles. Keep must retire the source that + * created the duplicate-decision hold before releasing it: otherwise a title marker is + * immediately re-ingested, while deleting PROMPT.md for a title-only redirect loses an + * operator-authored plan. Same-ID prompt/title markers remain one cleanup operation; + * conflicts deliberately retain both sources for explicit operator correction. + */ + if (!duplicateDismissalResolution.conflict && duplicateDismissalResolution.marker) { + const titleMarker = parseExplicitDuplicateMarker(existingTaskForDuplicateDismissal?.title ?? ""); + if (title === undefined && titleMarker?.canonicalId === duplicateDismissalResolution.marker.canonicalId) { + updates.title = `Duplicate redirect cleared: ${titleMarker.canonicalId}`; + } + } /* * FNXC:DuplicateIntake 2026-07-16-13:00: * Keep resolves Issue #2225's default triage-marker hold by acknowledging the link, @@ -6231,7 +6251,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } const task = await scopedStore.updateTask(req.params.id, updates); - if (dismissNearDuplicate === true && task.sourceMetadata?.duplicateSource === "triage-marker") { + if ( + dismissNearDuplicate === true + && task.sourceMetadata?.duplicateSource === "triage-marker" + && duplicateDismissalResolution?.source === "prompt" + ) { const { rm } = await import("node:fs/promises"); await rm(join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); } diff --git a/packages/engine/src/__tests__/executor-explicit-duplicate-recovery.test.ts b/packages/engine/src/__tests__/executor-explicit-duplicate-recovery.test.ts new file mode 100644 index 0000000000..b52a921eec --- /dev/null +++ b/packages/engine/src/__tests__/executor-explicit-duplicate-recovery.test.ts @@ -0,0 +1,75 @@ +import "./executor-test-helpers.js"; +import { describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Task } from "@fusion/core"; + +import { TaskExecutor } from "../executor.js"; +import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js"; + +function task(overrides: Partial = {}): Task { + return { + id: "KB-124", + title: "DUPLICATE: KB-123", + description: "Duplicate redirect", + column: "in-progress", + status: null, + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +/* +FNXC:DuplicateIntake 2026-08-09-01:54: +FN-8840 must recover an already-admitted title-only redirect at the graph failure boundary. +This fixture deliberately keeps PROMPT.md executable-looking, proving the title—not a prompt marker— +routes parse failure to replan before generic retry/terminal failure handling can run. +*/ +describe("executor explicit duplicate redirect parse recovery", () => { + it("rebounds a title-only custom-prefix redirect after a parse failure", async () => { + resetExecutorMocks(); + const tasksDir = await mkdtemp(join(tmpdir(), "fusion-duplicate-parse-")); + const store = createMockStore(); + const liveTask = task({ error: "parse error" }); + await mkdir(join(tasksDir, liveTask.id), { recursive: true }); + await writeFile(join(tasksDir, liveTask.id, "PROMPT.md"), "# Incomplete plan\n", "utf-8"); + store.getTasksDir = vi.fn(() => tasksDir); + store.getTask.mockResolvedValue(liveTask); + const executor = new TaskExecutor(store, "/tmp/test"); + + try { + await (executor as any).handleGraphFailure(liveTask, { + disposition: "failed", + outcome: "failure", + reason: "parse-error", + visitedNodeIds: ["parse"], + context: { "node:parse:value": "parse-error" }, + }); + + expect(store.moveTask).toHaveBeenCalledWith(liveTask.id, "todo", { preserveWorktree: true }); + expect(store.updateTask).toHaveBeenCalledWith(liveTask.id, { + status: "needs-replan", + error: null, + }, undefined); + expect(store.logEntry).toHaveBeenCalledWith( + liveTask.id, + "Parse node failed on duplicate redirect — rebounded to todo for re-specification", + expect.stringContaining("task title"), + undefined, + ); + expect(store.updateTask).not.toHaveBeenCalledWith( + liveTask.id, + expect.objectContaining({ status: "failed" }), + undefined, + ); + } finally { + await rm(tasksDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/engine/src/__tests__/merged-intake-hold-column.test.ts b/packages/engine/src/__tests__/merged-intake-hold-column.test.ts index 9ebddfe6f4..0dfac708ca 100644 --- a/packages/engine/src/__tests__/merged-intake-hold-column.test.ts +++ b/packages/engine/src/__tests__/merged-intake-hold-column.test.ts @@ -306,6 +306,25 @@ describe("a column carrying BOTH intake and hold (U11's merged Planning column)" await expect(isUnplannedForExecution(gateStore(), card, ir(MERGED))).resolves.toBe(true); }); + /* + FNXC:DuplicateIntake 2026-08-09-01:54: + FN-8840 makes the durable title an admission source. Check it without getTasksDir so a store + adapter cannot accidentally release a title-only custom-prefix redirect while prompt I/O is unavailable. + */ + it("holds a title-only custom-prefix redirect before prompt filesystem access", async () => { + const card = task({ id: "D2", title: "DUPLICATE: KB-123", description: "d", column: MERGED.hold }); + const storeWithoutTasksDir = { getSettings: vi.fn(async () => ({})) } as unknown as TaskStore; + + await expect(isUnplannedForExecution(storeWithoutTasksDir, card, ir(MERGED))).resolves.toBe(true); + }); + + it("does not hold incidental duplicate prose in a title", async () => { + const card = task({ id: "D3", title: "Discuss DUPLICATE: KB-123 before implementation", description: "d", column: MERGED.hold }); + seedPlannedPrompt("D3"); + + await expect(isUnplannedForExecution(gateStore(), card, ir(MERGED))).resolves.toBe(false); + }); + it("does not gate a card already in the wip column", async () => { const card = task({ id: "W1", title: "Working", description: "d", column: MERGED.wip }); seedUnplannedPrompt("W1", "Working", "d"); diff --git a/packages/engine/src/__tests__/scheduler-explicit-duplicate-marker.test.ts b/packages/engine/src/__tests__/scheduler-explicit-duplicate-marker.test.ts new file mode 100644 index 0000000000..ba464781e1 --- /dev/null +++ b/packages/engine/src/__tests__/scheduler-explicit-duplicate-marker.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { Settings, Task, TaskStore, WorkflowIr } from "@fusion/core"; + +import { Scheduler } from "../scheduler.js"; +import { seedPlannedSpec } from "./_planned-spec-fixture.js"; + +/* +FNXC:DuplicateIntake 2026-08-09-01:54: +FN-8840 requires scheduler admission to reject a durable exact title redirect before it tries to +read PROMPT.md. This production-boundary test makes getTasksDir throw, so it fails if a refactor +moves title evaluation below filesystem capability or accidentally permits a dispatchable redirect. +*/ +describe("scheduler explicit duplicate redirect admission", () => { + it("rejects a title-only custom-prefix redirect without prompt filesystem access", async () => { + const getTasksDir = vi.fn(() => { + throw new Error("PROMPT.md must not be read for a title redirect"); + }); + const scheduler = new Scheduler({ + getRootDir: () => "/tmp/test", + getTasksDir, + on: vi.fn(), + off: vi.fn(), + } as unknown as TaskStore); + + await expect((scheduler as any).validateTaskFilesystem({ + id: "KB-124", + title: "DUPLICATE: KB-123", + })).resolves.toEqual({ + valid: false, + reason: "task title is a duplicate redirect marker (DUPLICATE: KB-123), not an executable plan", + }); + expect(getTasksDir).not.toHaveBeenCalled(); + }); + + it("does not suppress ordinary title prose", async () => { + const scheduler = new Scheduler({ + getRootDir: () => "/tmp/test", + on: vi.fn(), + off: vi.fn(), + } as unknown as TaskStore); + + await expect((scheduler as any).validateTaskFilesystem({ + id: "KB-124", + title: "Discuss DUPLICATE: KB-123 before implementation", + })).resolves.toEqual({ valid: true }); + }); + + /* + FNXC:DuplicateIntake 2026-08-09-02:13: + FN-8840's scheduler guarantee is observable only through schedule(): an exact title redirect + must not cross the hold-to-WIP boundary even when its PROMPT.md is a complete executable plan. + The control uses the same store and spec with ordinary title prose, preventing a vacuous refusal. + */ + it("does not dispatch an exact title redirect through the scheduler admission path", async () => { + const workflowId = "custom:duplicate-title-admission"; + const task = { + id: "KB-124", + title: "DUPLICATE: KB-123", + description: "A complete plan is already present.", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + workflowStepResults: [{ + workflowStepId: "plan-review", + workflowStepName: "Plan Review", + status: "passed" as const, + source: "node" as const, + phase: "pre-merge" as const, + }], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as Task; + const ir = { + version: "v2", + id: workflowId, + nodes: [], + edges: [], + columns: [ + { id: "todo", label: "Planning", traits: [{ trait: "intake" }, { trait: "hold", config: { release: "capacity" } }] }, + { id: "in-progress", label: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "in-review", label: "Review", traits: [{ trait: "humanReview" }, { trait: "mergeBlocker" }] }, + { id: "done", label: "Done", traits: [{ trait: "complete" }] }, + ], + } as unknown as WorkflowIr; + const tasksDir = mkdtempSync(join(tmpdir(), "fusion-scheduler-duplicate-title-")); + const moveTask = vi.fn(async (_id: string, column: Task["column"]) => Object.assign(task, { column })); + const store = { + listTasks: vi.fn(async () => [task]), + getSettings: vi.fn(async () => ({ maxConcurrent: 1, maxWorktrees: 1 } as Settings)), + updateSettings: vi.fn(async () => undefined), + getTask: vi.fn(async () => task), + getTasksDir: vi.fn(() => tasksDir), + getRootDir: vi.fn(() => "/tmp/project"), + getTaskWorkflowSelection: vi.fn(() => ({ workflowId, stepIds: [] })), + getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId, stepIds: [] })), + getWorkflowDefinition: vi.fn(async () => ({ ir })), + moveTask, + moveTaskIf: vi.fn(async (_id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise) => { + if (!(await predicate(task))) return { task, moved: false }; + return { task: await moveTask(task.id, column), moved: true }; + }), + updateTask: vi.fn(async (_id: string, patch: Partial) => Object.assign(task, patch)), + logEntry: vi.fn(async () => undefined), + parseFileScopeFromPrompt: vi.fn(async () => []), + getCompletionHandoffAcceptedMarker: vi.fn(async () => null), + recordRunAuditEvent: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + } as unknown as TaskStore; + seedPlannedSpec(store as unknown as { getTasksDir(): string }, task.id, { title: task.title, description: task.description }); + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + expect(moveTask).not.toHaveBeenCalled(); + expect(task.column).toBe("todo"); + + task.title = "Discuss duplicate KB-123 before implementation"; + await scheduler.schedule(); + + expect(moveTask).toHaveBeenCalledWith(task.id, "in-progress"); + expect(task.column).toBe("in-progress"); + }); +}); 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 8e4d27c3a1..f3003af164 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 @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { Settings, Task, TaskStore } from "@fusion/core"; const { recordRunAuditEventMock } = vi.hoisted(() => ({ @@ -129,6 +132,70 @@ describe("FN-8356: reconcile stale duplicate-decision pauses", () => { Differential: `shipped` collides with no legacy literal, so a surviving `"done"` cannot pass here by luck, and the control above proves the default vocabulary still works. */ + it("preserves an executable prompt while clearing an inactive title-only redirect", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-stale-title-redirect-")); + const card = stranded("KB-1", "KB-404", { title: "DUPLICATE: KB-404" }); + const promptPath = join(root, ".fusion", "tasks", card.id, "PROMPT.md"); + await mkdir(join(root, ".fusion", "tasks", card.id), { recursive: true }); + await writeFile(promptPath, "# Operator-authored plan\n", "utf8"); + const store = storeFor([card]); + const manager = new SelfHealingManager(store, { rootDir: root }); + + try { + expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(1); + await expect(readFile(promptPath, "utf8")).resolves.toBe("# Operator-authored plan\n"); + expect(await store.getTask(card.id)).toMatchObject({ title: "Duplicate redirect cleared: KB-404" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("fails closed when stale metadata conflicts with prompt and title redirects", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-stale-duplicate-conflict-")); + const card = stranded("KB-1", "KB-404", { title: "DUPLICATE: KB-404" }); + const promptPath = join(root, ".fusion", "tasks", card.id, "PROMPT.md"); + await mkdir(join(root, ".fusion", "tasks", card.id), { recursive: true }); + await writeFile(promptPath, "DUPLICATE: KB-405\n", "utf8"); + const store = storeFor([card]); + const manager = new SelfHealingManager(store, { rootDir: root }); + + try { + expect(await manager.reconcileStaleDuplicateDecisionPause()).toBe(0); + await expect(readFile(promptPath, "utf8")).resolves.toBe("DUPLICATE: KB-405\n"); + expect(await store.getTask(card.id)).toMatchObject({ + title: "DUPLICATE: KB-404", + paused: true, + pausedReason: "duplicate-decision-required", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("self-healing resolves a title-only custom-prefix redirect without discarding its plan", async () => { + const root = await mkdtemp(join(tmpdir(), "fusion-title-marker-sweep-")); + const card = task("KB-1", { title: "DUPLICATE: KB-123" }); + const canonical = task("KB-123", { column: "todo" }); + const promptPath = join(root, ".fusion", "tasks", card.id, "PROMPT.md"); + await mkdir(join(root, ".fusion", "tasks", card.id), { recursive: true }); + await writeFile(promptPath, "# Operator-authored plan\n", "utf8"); + const store = storeFor([card, canonical]); + (store.getSettings as ReturnType).mockResolvedValue({ triageDuplicateResolution: "keep" } as Settings); + const manager = new SelfHealingManager(store, { rootDir: root }); + vi.spyOn(manager as any, "filterByPreWipRole").mockResolvedValue([card]); + + try { + expect(await manager.resolveExplicitDuplicateMarkerTasks()).toBe(1); + await expect(readFile(promptPath, "utf8")).resolves.toBe("# Operator-authored plan\n"); + expect(await store.getTask(card.id)).toMatchObject({ + title: "Duplicate redirect cleared: KB-123", + status: "needs-replan", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("renamed vocabulary: clears the decision for a canonical resting in a RENAMED complete column", async () => { const shipped = task("FN-SHIPPED", { column: "shipped" }); const strandedCard = stranded("FN-1", shipped.id, { column: "drafting" }); 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 e0f294508c..5ff49a4bb9 100644 --- a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts +++ b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { Settings, Task, TaskStore } from "@fusion/core"; import { TriageProcessor } from "../triage.js"; @@ -76,6 +79,25 @@ describe("triage explicit duplicate marker short-circuit", () => { }); + it("resolves an exact title redirect before starting a planner session", async () => { + const canonical = createTask({ id: "KB-123", title: "Canonical task", column: "todo" }); + const task = createTask({ title: "DUPLICATE: KB-123", status: null }); + const onSpecifyStart = vi.fn(); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + }); + const processor = new TriageProcessor(store, rootDir, { onSpecifyStart }); + + await processor.specifyTask(task); + + expect(onSpecifyStart).not.toHaveBeenCalled(); + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + paused: true, + pausedReason: "duplicate-decision-required", + sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "KB-123" }), + })); + }); + it("flags and system-pauses duplicates by default instead of deleting", async () => { const canonical = createTask({ id: "FN-001", column: "todo" }); const task = createTask(); @@ -127,6 +149,35 @@ describe("triage explicit duplicate marker short-circuit", () => { ); }); + it("keeps an executable prompt when clearing a title-only redirect", async () => { + const task = createTask({ title: "DUPLICATE: KB-123" }); + const canonical = createTask({ id: "KB-123", title: "Canonical task", column: "todo" }); + const root = await mkdtemp(join(tmpdir(), "fusion-title-redirect-")); + const promptPath = join(root, ".fusion", "tasks", task.id, "PROMPT.md"); + await mkdir(join(root, ".fusion", "tasks", task.id), { recursive: true }); + await writeFile(promptPath, "# Complete operator-authored plan\n", "utf8"); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + withTaskLock: vi.fn().mockImplementation(async (_id: string, operation: () => Promise) => await operation()), + readTaskForMove: vi.fn().mockResolvedValue(task), + }); + + try { + const processor = new TriageProcessor(store, root); + await expect((processor as any).tryFinalizeExplicitDuplicateMarker( + task, + "# Complete operator-authored plan\n", + { ...settings, triageDuplicateResolution: "keep" }, + {}, + )).resolves.toBe(true); + + await expect(readFile(promptPath, "utf8")).resolves.toBe("# Complete operator-authored plan\n"); + expect(store.updateTask).toHaveBeenCalledWith(task.id, { title: "Duplicate redirect cleared: KB-123" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + 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({ diff --git a/packages/engine/src/execution/hold-release.ts b/packages/engine/src/execution/hold-release.ts index d5d0cea925..04e655f615 100644 --- a/packages/engine/src/execution/hold-release.ts +++ b/packages/engine/src/execution/hold-release.ts @@ -289,6 +289,14 @@ export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: */ if (task.status === "needs-replan") return true; + /* + FNXC:DuplicateIntake 2026-08-09-01:02: + The title is already durable task state, so its exact redirect must hold capacity before the + store capability check and prompt read. This keeps title-only redirects non-dispatchable even + for adapters without task directories or during prompt I/O failures. + */ + if (isDuplicateRedirectOnlyPrompt(null, task.title)) return true; + /* FNXC:WorkflowScheduling 2026-07-19-02:10 (U4): Gate the bootstrap-stub check on the TRAIT, not the literal "todo" id. An @@ -313,7 +321,7 @@ export async function isUnplannedForExecution(store: TaskStore, task: Task, ir: A DUPLICATE-only PROMPT is unplanned for execution (FN-8704). Hold capacity release until triage writes a real plan — filesystem validation is the twin of this check. */ - if (isDuplicateRedirectOnlyPrompt(promptContent)) return true; + if (isDuplicateRedirectOnlyPrompt(promptContent, task.title)) return true; return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description); } catch { // Missing prompt is handled by filesystem validation elsewhere; do not block on it here. diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 6fd0b1f1c2..33e282d9de 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -16,7 +16,7 @@ import { DEFAULT_PROVIDER_INSTANCE_ID, type ProviderInstanceRef, type TaskStore, import { getUnmetSchedulingDependencies } from "./scheduler.js"; import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js"; import { emitWorkflowLifecycleEvent } from "@fusion/core"; -import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, hasSharedBranchMemberAutoMergeHold, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, classifyWorkflowAgentNode, isWorkflowAgentRole, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core"; +import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, hasSharedBranchMemberAutoMergeHold, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, classifyWorkflowAgentNode, isWorkflowAgentRole, resolveExecutorFallbackModel, resolveValidatorFallbackModel, resolveExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core"; import { BLOCKED_THRASH_LIMIT, buildExternalBlockMetadataPatch, @@ -12688,9 +12688,10 @@ export class TaskExecutor { ? this.store.getTasksDir() : join(this.rootDir, ".fusion", "tasks"); const promptContent = await readFile(getPromptPath(tasksDir, live.id), "utf-8").catch(() => ""); - const redirectReason = nonExecutableDuplicateRedirectReason(promptContent); + const redirectReason = nonExecutableDuplicateRedirectReason(promptContent, live.title); if (redirectReason) { - const marker = parseExplicitDuplicateMarker(promptContent); + const duplicateResolution = resolveExplicitDuplicateMarker(promptContent, live.title); + const marker = duplicateResolution.marker; const replanColumn = await resolveReplanTargetColumn(this.store, live.id); await moveTaskToReplanColumn(this.store, { id: live.id, column: live.column }, replanColumn); await this.store.updateTask(live.id, { @@ -12698,8 +12699,8 @@ export class TaskExecutor { error: null, }, this.getRunContextFor(live.id)); const feedback = marker - ? `Execution parse rejected non-executable PROMPT.md (DUPLICATE: ${marker.canonicalId}). Write a full plan body; do not re-emit only DUPLICATE: ${marker.canonicalId}.` - : `Execution parse rejected non-executable PROMPT.md (${redirectReason}). Write a full plan body.`; + ? `Execution parse rejected non-executable duplicate redirect (DUPLICATE: ${marker.canonicalId}). Write a full plan body; do not re-emit only DUPLICATE: ${marker.canonicalId}.` + : `Execution parse rejected conflicting duplicate redirects (${redirectReason}). Correct the title or PROMPT.md before writing a full plan body.`; await this.store.logEntry( live.id, "AI spec revision requested", diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index c362ec22b3..b5a1770f4f 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -1511,7 +1511,16 @@ export class Scheduler { * @param id - The task ID to validate * @returns Object with `valid: true` if checks pass, or `valid: false` with a `reason` string if they fail */ - private async validateTaskFilesystem(id: string): Promise<{ valid: boolean; reason?: string }> { + private async validateTaskFilesystem(task: Pick): Promise<{ valid: boolean; reason?: string }> { + const id = task.id; + /* + FNXC:DuplicateIntake 2026-08-09-01:02: + A title redirect is available without filesystem capability or PROMPT.md I/O. Refuse it first + so minimal stores and missing artifacts cannot dispatch a task the operator explicitly marked + DUPLICATE. + */ + const titleRedirect = nonExecutableDuplicateRedirectReason(null, task.title); + if (titleRedirect) return { valid: false, reason: titleRedirect }; if (typeof this.store.getTasksDir !== "function") { /* FNXC:WorkflowScheduling 2026-06-23-11:38: @@ -1542,7 +1551,7 @@ export class Scheduler { Non-empty is not enough: a sole `DUPLICATE: FN-####` line is a triage redirect, not a plan. Admitting it (FN-8704) fails the graph at `parse` and parks failed WIP in a loop. */ - const duplicateOnly = nonExecutableDuplicateRedirectReason(content); + const duplicateOnly = nonExecutableDuplicateRedirectReason(content, task.title); if (duplicateOnly) { return { valid: false, reason: duplicateOnly }; } @@ -2452,7 +2461,7 @@ export class Scheduler { FNXC:WorkflowScheduling 2026-06-23-11:12: The workflow sweep is the only dispatcher, so the scheduler-only pre-dispatch gates must run before a capacity hold moves to an execution column. Keep dependency, filesystem, node-routing, permanent-agent, and oscillation checks on this path instead of relying on the retired todo loop. */ - const validation = await this.validateTaskFilesystem(task.id); + const validation = await this.validateTaskFilesystem(task); if (!validation.valid) { schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`); /* diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 7b26a2fcf9..a725727e49 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -30,7 +30,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, hasSharedBranchMemberAutoMergeHold, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isLiveSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, resolveArchiveTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, 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, type WorkflowIr, +import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, hasSharedBranchMemberAutoMergeHold, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isLiveSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, resolveExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, resolveArchiveTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, 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, type WorkflowIr, resolveNearDuplicateCanonicalFlags, LEGACY_COLUMN_IDS_BY_ROLE, TERMINAL_ROLES, @@ -7033,16 +7033,29 @@ export class SelfHealingManager extends SelfHealingGitEvidence { 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)) { + const written = existsSync(promptPath) ? readFileSync(promptPath, "utf-8") : ""; + const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title); + const canonicalMarkerId = canonicalId.toUpperCase(); + /* + FNXC:DuplicateIntake 2026-08-09-02:54: + FN-8840 requires stale-decision recovery to fail closed when PROMPT.md and the title + disagree, or when either points to a different canonical than the stale metadata. Never + erase either operator redirect or release its decision hold without an unambiguous match. + */ + if (duplicateResolution.conflict + || (duplicateResolution.marker && duplicateResolution.marker.canonicalId !== canonicalMarkerId)) { + continue; + } + if (duplicateResolution.source === "prompt") { try { - const written = readFileSync(promptPath, "utf-8"); - if (parseExplicitDuplicateMarker(written)) { - rmSync(promptPath, { force: true }); - } + rmSync(promptPath, { force: true }); } catch { // best-effort marker removal; status write still proceeds } } + if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === canonicalMarkerId) { + await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${canonicalMarkerId}` }); + } await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalId)); if (typeof this.store.logEntry === "function") { await Promise.resolve(this.store.logEntry( @@ -14440,15 +14453,13 @@ const movedTask = await this.store.moveTask(task.id, completeLane); for (const task of candidates) { try { const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"); - if (!existsSync(promptPath)) { - continue; - } - - const written = readFileSync(promptPath, "utf-8"); - const marker = parseExplicitDuplicateMarker(written); - if (!marker) { + const written = existsSync(promptPath) ? readFileSync(promptPath, "utf-8") : ""; + const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title); + // A conflict has no safe canonical target; leave it for planning/operator correction. + if (!duplicateResolution.marker || duplicateResolution.conflict) { continue; } + const marker = duplicateResolution.marker; if (processedMarkers >= 50) { break; } @@ -14475,7 +14486,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane); const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask); if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) { if (canClearInactiveMarker) { - rmSync(promptPath, { force: true }); + // FNXC:DuplicateIntake 2026-08-09-02:14: preserve a real PROMPT.md for a title-only redirect. + if (duplicateResolution.source !== "title") rmSync(promptPath, { force: true }); + if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === marker.canonicalId) { + await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${marker.canonicalId}` }); + } const priorClearCount = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number" ? task.sourceMetadata.duplicateMarkerClearCount : 0; @@ -14502,7 +14517,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane); */ if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) { if (canClearInactiveMarker) { - rmSync(promptPath, { force: true }); + // FNXC:DuplicateIntake 2026-08-09-02:14: preserve a real PROMPT.md for a title-only redirect. + if (duplicateResolution.source !== "title") rmSync(promptPath, { force: true }); + if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === marker.canonicalId) { + await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${marker.canonicalId}` }); + } const priorKeepClears = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number" ? task.sourceMetadata.duplicateMarkerClearCount : 0; @@ -14532,7 +14551,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane); await flagTriageDuplicate(this.store, task.id, canonicalTask.id); await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null }); } else { - rmSync(promptPath, { force: true }); + // FNXC:DuplicateIntake 2026-08-09-02:14: title-only redirects must not erase executable prompts. + if (duplicateResolution.source !== "title") rmSync(promptPath, { force: true }); + if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === marker.canonicalId) { + await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${marker.canonicalId}` }); + } await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id)); if (typeof this.store.logEntry === "function") { await Promise.resolve(this.store.logEntry( diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index dee95b7a3a..2461f02fac 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -22,7 +22,7 @@ import { isUnplannedSeedPrompt, isTaskAwaitingPlanning, getTaskDuplicateLineage, - parseExplicitDuplicateMarker, + resolveExplicitDuplicateMarker, resolveAgentPrompt, buildPlanningDuplicatePolicyInstruction, builtinSeamPrompt, @@ -1427,7 +1427,7 @@ export class TriageProcessor { to flag/delete/clear in finalizeApprovedTask. Requiring step headings for those markers withheld recovery forever (empty steps) so the marker path never ran. */ - const isExplicitDuplicateRedirect = Boolean(parseExplicitDuplicateMarker(written)); + const isExplicitDuplicateRedirect = Boolean(resolveExplicitDuplicateMarker(written, task.title).marker); const workflow = await resolveWorkflowIrForTask(this.store, task.id).catch(() => undefined); const requiresPromptImplementationSteps = workflow?.nodes.some((node) => node.kind === "parse-steps" @@ -2435,6 +2435,45 @@ export class TriageProcessor { } } + /** + * Resolves an exact prompt/title redirect before this task claims any planning capacity. + * + * FNXC:DuplicateIntake 2026-08-09-01:31: + * FN-8840 requires title redirects to take the same duplicate-decision route as prompt + * redirects before `specifyTask()` can start a planner session. Reading the prompt here is + * required only to detect a conflicting exact marker; a missing or unreadable prompt leaves + * a title-only redirect actionable and never lets it consume an implementation session. + */ + private async finalizeExplicitDuplicateBeforePlanning(task: Task): Promise { + try { + const liveTask = await this.store.getTask(task.id).catch(() => null); + if (!liveTask || liveTask.paused === true || liveTask.userPaused === true) return false; + + const promptPath = join(this.rootDir, ".fusion", "tasks", liveTask.id, "PROMPT.md"); + const written = await readFile(promptPath, "utf-8").catch(() => ""); + const duplicateResolution = resolveExplicitDuplicateMarker(written, liveTask.title); + if (!duplicateResolution.marker && !duplicateResolution.conflict) return false; + + const settings = await mergeEffectiveSettings(this.store, liveTask, await this.store.getSettings()); + if (duplicateResolution.conflict) { + await this.finalizeApprovedTask(liveTask, written, settings); + return true; + } + + return await this.tryFinalizeExplicitDuplicateMarker(liveTask, written, settings); + } catch (error: unknown) { + /* + FNXC:DuplicateIntake 2026-08-09-01:49: + Duplicate detection is an admission optimization, not a second source of planning failure. + If settings or lifecycle finalization is unavailable, retain the existing fail-open planner + path so a pre-held coordinator slot cannot leak before `specifyTask()` reaches its cleanup. + */ + const message = error instanceof Error ? error.message : String(error); + planLog.warn(`${task.id}: pre-planning duplicate resolution failed open: ${message}`); + return false; + } + } + async specifyTask(task: Task): Promise { /* FNXC:TriageStuckKill 2026-07-18-21:05: @@ -2455,6 +2494,12 @@ export class TriageProcessor { return; } + if (await this.finalizeExplicitDuplicateBeforePlanning(task)) { + if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release(); + this.coordinatorAdmittedTaskIds.delete(task.id); + return; + } + if (await this.flagImportNearDuplicateBeforePlanning(task)) { if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release(); return; @@ -4251,12 +4296,12 @@ export class TriageProcessor { report: PlanningHandoffReport = { outcome: "parked" }, ): Promise { try { - const explicitDuplicateMarker = parseExplicitDuplicateMarker(written); - if (!explicitDuplicateMarker) { + const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title); + if (!duplicateResolution.marker || duplicateResolution.conflict) { return false; } - const canonicalId = explicitDuplicateMarker.canonicalId; + const canonicalId = duplicateResolution.marker.canonicalId; // 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()) { @@ -4446,10 +4491,23 @@ export class TriageProcessor { task: Task, canonicalId: string, feedback: string, - options?: { exhausted?: boolean; priorClearCount?: number }, + options?: { exhausted?: boolean; priorClearCount?: number; source?: "prompt" | "title" }, ): Promise { if (!await this.runIfStillPlanningUnderTaskLock(task, async () => { - await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); + /* + FNXC:DuplicateIntake 2026-08-09-02:14: + A title-only redirect can coexist with a complete operator-authored PROMPT.md. Keep that + plan when clearing the title source; deleting it would turn an acknowledged redirect into + avoidable user-work loss. A prompt source (including same-ID dual sources) still clears the + marker-only file, and the matching title is cleared with it. + */ + if (options?.source !== "title") { + await rm(join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); + } + // Same-ID dual-source redirects are one decision; clear both exact sources together. + if (resolveExplicitDuplicateMarker(null, task.title).marker?.canonicalId === canonicalId) { + await this.store.updateTask(task.id, { title: `Duplicate redirect cleared: ${canonicalId}` }); + } })) return false; const priorClearCount = options?.priorClearCount ?? 0; @@ -4494,11 +4552,21 @@ export class TriageProcessor { report: PlanningHandoffReport = { outcome: "parked" }, ): Promise { let written = writtenInput; - // FNXC:WorkflowArtifacts 2026-07-21-17:00: Confirm the authoritative plan - // exists before persisting any dependencies, steps, metadata, or review state - // derived from it; a missing plan must leave no partially accepted projection. - if (await this.recoverMissingPromptBeforeRelease(task)) return; - const explicitDuplicateMarker = parseExplicitDuplicateMarker(written); + const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title); + if (duplicateResolution.conflict) { + /* + FNXC:DuplicateIntake 2026-08-09-01:02: + Conflicting exact title and prompt redirects must never select a canonical implicitly. + Keep the card in planning for operator correction rather than admitting it or inventing a + duplicate decision. + */ + await this.updatePlanningStateIfStillCurrent(task, { status: "needs-replan", error: null }); + await this.store.logEntry(task.id, "Duplicate redirect sources conflict", "PROMPT.md and task title name different canonical tasks; correct one exact redirect before planning."); + return; + } + // A title-only redirect is authoritative even when there is no prompt file to recover. + if (!duplicateResolution.marker && await this.recoverMissingPromptBeforeRelease(task)) return; + const explicitDuplicateMarker = duplicateResolution.marker; /* * FNXC:DuplicateIntake 2026-07-16-13:00: @@ -4507,6 +4575,7 @@ export class TriageProcessor { */ if (explicitDuplicateMarker) { const canonicalId = explicitDuplicateMarker.canonicalId; + const duplicateSource = duplicateResolution.source ?? "prompt"; const canonicalTask = await this.store.getTask(canonicalId).catch(() => null); const canClearInactiveMarker = task.userPaused !== true && (task.paused !== true || task.pausedReason === "duplicate-decision-required") @@ -4540,7 +4609,7 @@ export class TriageProcessor { task, canonicalId, buildInactiveDuplicateClearFeedback(canonicalId), - { exhausted: false, priorClearCount }, + { exhausted: false, priorClearCount, source: duplicateSource }, ); } return; @@ -4561,7 +4630,7 @@ export class TriageProcessor { task, canonicalId, buildKeepDuplicateClearFeedback(canonicalId), - { exhausted: priorClearCount >= 1, priorClearCount }, + { exhausted: priorClearCount >= 1, priorClearCount, source: duplicateSource }, ); } return; @@ -4598,6 +4667,7 @@ export class TriageProcessor { task, canonicalId, buildKeepDuplicateClearFeedback(canonicalId), + { source: duplicateSource }, ); return; }