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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-08 19:58:43 -07:00
parent fe9fe88841
commit f36e23848f
21 changed files with 687 additions and 78 deletions

View File

@@ -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.

View File

@@ -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`

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -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() };
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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> = {}): Task {
/** Write PROMPT.md for a task; omit `content` to leave the file missing. */
async function seedTaskDir(taskId: string, content?: string): Promise<void> {
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<string, unknown>) => {
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" });

View File

@@ -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<typeof resolveExplicitDuplicateMarker> | 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 });
}

View File

@@ -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> = {}): 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 });
}
});
});

View File

@@ -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");

View File

@@ -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<boolean>) => {
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<Task>) => 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");
});
});

View File

@@ -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<typeof vi.fn>).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" });

View File

@@ -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<unknown>) => 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({

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<Task, "id" | "title">): 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}`);
/*

View File

@@ -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(

View File

@@ -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<boolean> {
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<void> {
/*
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<boolean> {
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<boolean> {
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<void> {
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;
}