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:
7
.changeset/fn-8840-duplicate-title-redirect.md
Normal file
7
.changeset/fn-8840-duplicate-title-redirect.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
/*
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user