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.
|
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`
|
### `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:
|
Fusion also recognizes the canonical one-line redirect marker:
|
||||||
|
|
||||||
- `DUPLICATE: FN-1234`
|
- `DUPLICATE: FN-1234`
|
||||||
- `` `DUPLICATE: FN-1234` ``
|
- `DUPLICATE: KB-1234`
|
||||||
- `**DUPLICATE: FN-1234**`
|
- `` `DUPLICATE: KB-1234` ``
|
||||||
|
- `**DUPLICATE: KB-1234**`
|
||||||
- fenced single-line wrappers such as:
|
- fenced single-line wrappers such as:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DUPLICATE: FN-1234
|
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:
|
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:
|
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.
|
- **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`).
|
- **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.
|
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 {
|
import {
|
||||||
parseExplicitDuplicateMarker,
|
parseExplicitDuplicateMarker,
|
||||||
|
resolveExplicitDuplicateMarker,
|
||||||
parseDuplicateMarkerFromSessionText,
|
parseDuplicateMarkerFromSessionText,
|
||||||
isDuplicateRedirectOnlyPrompt,
|
isDuplicateRedirectOnlyPrompt,
|
||||||
nonExecutableDuplicateRedirectReason,
|
nonExecutableDuplicateRedirectReason,
|
||||||
@@ -69,13 +70,32 @@ describe("parseExplicitDuplicateMarker", () => {
|
|||||||
expect(parseExplicitDuplicateMarker(" ")).toBeNull();
|
expect(parseExplicitDuplicateMarker(" ")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-FN identifiers", () => {
|
it("accepts custom task prefixes and normalizes them", () => {
|
||||||
expect(parseExplicitDuplicateMarker("DUPLICATE: NOT-1234")).toBeNull();
|
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("DUPLICATE: FN-8676")).toBe(true);
|
||||||
|
expect(isDuplicateRedirectOnlyPrompt(FULL_PROMPT, "DUPLICATE: KB-8676")).toBe(true);
|
||||||
expect(nonExecutableDuplicateRedirectReason("DUPLICATE: FN-8676")).toContain("FN-8676");
|
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(isDuplicateRedirectOnlyPrompt(FULL_PROMPT)).toBe(false);
|
||||||
expect(nonExecutableDuplicateRedirectReason(FULL_PROMPT)).toBeNull();
|
expect(nonExecutableDuplicateRedirectReason(FULL_PROMPT)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -113,9 +133,9 @@ describe("parseDuplicateMarkerFromSessionText", () => {
|
|||||||
expect(parseDuplicateMarkerFromSessionText(reply)).toEqual({ canonicalId: "FN-1" });
|
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-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", () => {
|
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);
|
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", () => {
|
it("ignores statuses that are not planning parks", () => {
|
||||||
for (const status of [undefined, null, "planning", "executing", "failed"]) {
|
for (const status of [undefined, null, "planning", "executing", "failed"]) {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ export interface ExplicitDuplicateMarker {
|
|||||||
canonicalId: string;
|
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 {
|
function stripCodeFenceLayer(content: string): string {
|
||||||
const fenceMatch = content.match(/^```(?:[\t ]*(?:text|markdown))?[\t ]*\n([\s\S]*?)\n```$/i);
|
const fenceMatch = content.match(/^```(?:[\t ]*(?:text|markdown))?[\t ]*\n([\s\S]*?)\n```$/i);
|
||||||
if (!fenceMatch) {
|
if (!fenceMatch) {
|
||||||
@@ -41,7 +51,7 @@ export function parseExplicitDuplicateMarker(content: string): ExplicitDuplicate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidate = stripSingleWrapper(nonBlankLines[0] ?? "");
|
const candidate = stripSingleWrapper(nonBlankLines[0] ?? "");
|
||||||
const match = candidate.match(/^DUPLICATE:\s*(FN-\d+)\s*$/i);
|
const match = candidate.match(DUPLICATE_MARKER_PATTERN);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -52,26 +62,44 @@ export function parseExplicitDuplicateMarker(content: string): ExplicitDuplicate
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:DuplicateIntake 2026-08-01-19:24:
|
FNXC:DuplicateIntake 2026-08-09-01:02:
|
||||||
A PROMPT.md whose entire body is `DUPLICATE: FN-####` is a triage redirect verdict, not an
|
FN-8840 requires every duplicate-decision and admission surface to evaluate the exact marker in
|
||||||
executable plan. FN-8704 was admitted into WIP because filesystem validation only required
|
both PROMPT.md and the task title. Prompt wins only when both sources name the same canonical ID;
|
||||||
non-empty content; the graph then failed at the `parse` node and parked failed in-progress in a
|
conflicting redirects fail closed so Fusion never silently chooses an operator's target.
|
||||||
loop. Shared predicate so dispatch, hold-release, awaiting-planning badges, and graph-failure
|
|
||||||
recovery all reject this shape the same way.
|
|
||||||
*/
|
*/
|
||||||
/** True when content is solely an explicit triage duplicate redirect (not a real plan body). */
|
export function resolveExplicitDuplicateMarker(
|
||||||
export function isDuplicateRedirectOnlyPrompt(content: string): boolean {
|
promptContent: string | null | undefined,
|
||||||
return parseExplicitDuplicateMarker(content) !== null;
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** True when the exact prompt/title contract blocks execution pending duplicate resolution. */
|
||||||
* Operator-facing reason for rejecting a non-executable PROMPT at dispatch, or null when content
|
export function isDuplicateRedirectOnlyPrompt(content: string | null | undefined, title?: string | null): boolean {
|
||||||
* is not a duplicate-only redirect.
|
const resolution = resolveExplicitDuplicateMarker(content, title);
|
||||||
*/
|
return resolution.marker !== null || resolution.conflict;
|
||||||
export function nonExecutableDuplicateRedirectReason(content: string): string | null {
|
}
|
||||||
const marker = parseExplicitDuplicateMarker(content);
|
|
||||||
if (!marker) return null;
|
/** Operator-facing dispatch refusal reason, preserving the source that declared the redirect. */
|
||||||
return `PROMPT.md is a duplicate redirect marker (DUPLICATE: ${marker.canonicalId}), not an executable plan`;
|
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")) {
|
for (const rawLine of text.split("\n")) {
|
||||||
const candidate = stripSingleWrapper(rawLine.trim());
|
const candidate = stripSingleWrapper(rawLine.trim());
|
||||||
const match = candidate.match(/^DUPLICATE:\s*(FN-\d+)\s*$/i);
|
const match = candidate.match(DUPLICATE_MARKER_PATTERN);
|
||||||
if (match) {
|
if (match) {
|
||||||
return { canonicalId: match[1].toUpperCase() };
|
return { canonicalId: match[1].toUpperCase() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -749,9 +749,12 @@ export { getTaskDuplicateLineage } from "./duplicates/duplicate-lineage.js";
|
|||||||
export {
|
export {
|
||||||
parseDuplicateMarkerFromSessionText,
|
parseDuplicateMarkerFromSessionText,
|
||||||
parseExplicitDuplicateMarker,
|
parseExplicitDuplicateMarker,
|
||||||
|
resolveExplicitDuplicateMarker,
|
||||||
isDuplicateRedirectOnlyPrompt,
|
isDuplicateRedirectOnlyPrompt,
|
||||||
nonExecutableDuplicateRedirectReason,
|
nonExecutableDuplicateRedirectReason,
|
||||||
type ExplicitDuplicateMarker,
|
type ExplicitDuplicateMarker,
|
||||||
|
type ExplicitDuplicateMarkerResolution,
|
||||||
|
type ExplicitDuplicateMarkerSource,
|
||||||
} from "./duplicates/explicit-duplicate-marker.js";
|
} from "./duplicates/explicit-duplicate-marker.js";
|
||||||
export {
|
export {
|
||||||
parseNoOpCompletionMarker,
|
parseNoOpCompletionMarker,
|
||||||
|
|||||||
@@ -871,7 +871,10 @@ export { getTaskDuplicateLineage } from "./duplicates/duplicate-lineage.js";
|
|||||||
export {
|
export {
|
||||||
parseDuplicateMarkerFromSessionText,
|
parseDuplicateMarkerFromSessionText,
|
||||||
parseExplicitDuplicateMarker,
|
parseExplicitDuplicateMarker,
|
||||||
|
resolveExplicitDuplicateMarker,
|
||||||
type ExplicitDuplicateMarker,
|
type ExplicitDuplicateMarker,
|
||||||
|
type ExplicitDuplicateMarkerResolution,
|
||||||
|
type ExplicitDuplicateMarkerSource,
|
||||||
isDuplicateRedirectOnlyPrompt,
|
isDuplicateRedirectOnlyPrompt,
|
||||||
nonExecutableDuplicateRedirectReason,
|
nonExecutableDuplicateRedirectReason,
|
||||||
} from "./duplicates/explicit-duplicate-marker.js";
|
} 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
|
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.
|
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);
|
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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import express from "express";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { TaskStore, Task } from "@fusion/core";
|
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. */
|
/** Write PROMPT.md for a task; omit `content` to leave the file missing. */
|
||||||
async function seedTaskDir(taskId: string, content?: string): Promise<void> {
|
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 });
|
await mkdir(dir, { recursive: true });
|
||||||
if (content !== undefined) await writeFile(join(dir, "PROMPT.md"), content);
|
if (content !== undefined) await writeFile(join(dir, "PROMPT.md"), content);
|
||||||
}
|
}
|
||||||
@@ -76,10 +76,21 @@ const RENAMED_HOLD_IR = {
|
|||||||
|
|
||||||
function createHarness(tasks: Task[], workflowIrs?: unknown[]) {
|
function createHarness(tasks: Task[], workflowIrs?: unknown[]) {
|
||||||
const store: TaskStore = {
|
const store: TaskStore = {
|
||||||
getRootDir: vi.fn(() => process.cwd()),
|
getRootDir: vi.fn(() => tasksRoot),
|
||||||
getProjectScopedPluginMcpServers: vi.fn(async () => []),
|
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 () => ({})),
|
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),
|
listTasks: vi.fn(async () => tasks),
|
||||||
...(workflowIrs ? { listWorkflowDefinitions: vi.fn(async () => workflowIrs.map((ir) => ({ ir }))) } : {}),
|
...(workflowIrs ? { listWorkflowDefinitions: vi.fn(async () => workflowIrs.map((ir) => ({ ir }))) } : {}),
|
||||||
} as unknown as TaskStore;
|
} 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
|
// 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.
|
// the client must fall back instead of being handed a fabricated label.
|
||||||
const task = makeTask({ id: "FN-EISDIR" });
|
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]);
|
const [row] = await fetchTasks([task]);
|
||||||
|
|
||||||
@@ -204,9 +215,9 @@ describe("GET /tasks awaitingPlanning enrichment", () => {
|
|||||||
await seedTaskDir("FN-RENAMED", REAL_SPEC);
|
await seedTaskDir("FN-RENAMED", REAL_SPEC);
|
||||||
|
|
||||||
const store = {
|
const store = {
|
||||||
getRootDir: vi.fn(() => process.cwd()),
|
getRootDir: vi.fn(() => tasksRoot),
|
||||||
getProjectScopedPluginMcpServers: vi.fn(async () => []),
|
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 () => ({})),
|
getSettingsFast: vi.fn(async () => ({})),
|
||||||
listTasks: vi.fn(async () => [task]),
|
listTasks: vi.fn(async () => [task]),
|
||||||
listWorkflowDefinitions: vi.fn(async () => [{
|
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);
|
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 () => {
|
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.
|
// Best-effort contract: a store without getTaskDir must not fail the board load.
|
||||||
const task = makeTask({ id: "FN-NODIR" });
|
const task = makeTask({ id: "FN-NODIR" });
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
resolveNearDuplicateCanonicalFlags,
|
resolveNearDuplicateCanonicalFlags,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
parseExplicitDuplicateMarker,
|
parseExplicitDuplicateMarker,
|
||||||
|
resolveExplicitDuplicateMarker,
|
||||||
resolveWorkflowIrForTask,
|
resolveWorkflowIrForTask,
|
||||||
resolveWorkflowIrForTaskWithProvenance,
|
resolveWorkflowIrForTaskWithProvenance,
|
||||||
resolveReviewColumns,
|
resolveReviewColumns,
|
||||||
@@ -6193,9 +6194,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
const existingTaskForDuplicateDismissal = dismissNearDuplicate === true
|
const existingTaskForDuplicateDismissal = dismissNearDuplicate === true
|
||||||
? await scopedStore.getTask(req.params.id)
|
? await scopedStore.getTask(req.params.id)
|
||||||
: null;
|
: null;
|
||||||
|
let duplicateDismissalResolution: ReturnType<typeof resolveExplicitDuplicateMarker> | null = null;
|
||||||
if (dismissNearDuplicate === true) {
|
if (dismissNearDuplicate === true) {
|
||||||
const isTriageMarkerDecision = existingTaskForDuplicateDismissal?.sourceMetadata?.duplicateSource === "triage-marker"
|
const isTriageMarkerDecision = existingTaskForDuplicateDismissal?.sourceMetadata?.duplicateSource === "triage-marker"
|
||||||
&& existingTaskForDuplicateDismissal.pausedReason === "duplicate-decision-required";
|
&& 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:
|
* FNXC:DuplicateIntake 2026-07-16-13:00:
|
||||||
* Keep resolves Issue #2225's default triage-marker hold by acknowledging the link,
|
* 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);
|
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");
|
const { rm } = await import("node:fs/promises");
|
||||||
await rm(join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"), { force: true });
|
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);
|
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 () => {
|
it("does not gate a card already in the wip column", async () => {
|
||||||
const card = task({ id: "W1", title: "Working", description: "d", column: MERGED.wip });
|
const card = task({ id: "W1", title: "Working", description: "d", column: MERGED.wip });
|
||||||
seedUnplannedPrompt("W1", "Working", "d");
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
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";
|
import type { Settings, Task, TaskStore } from "@fusion/core";
|
||||||
|
|
||||||
const { recordRunAuditEventMock } = vi.hoisted(() => ({
|
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
|
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.
|
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 () => {
|
it("renamed vocabulary: clears the decision for a canonical resting in a RENAMED complete column", async () => {
|
||||||
const shipped = task("FN-SHIPPED", { column: "shipped" });
|
const shipped = task("FN-SHIPPED", { column: "shipped" });
|
||||||
const strandedCard = stranded("FN-1", shipped.id, { column: "drafting" });
|
const strandedCard = stranded("FN-1", shipped.id, { column: "drafting" });
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 type { Settings, Task, TaskStore } from "@fusion/core";
|
||||||
|
|
||||||
import { TriageProcessor } from "../triage.js";
|
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 () => {
|
it("flags and system-pauses duplicates by default instead of deleting", async () => {
|
||||||
const canonical = createTask({ id: "FN-001", column: "todo" });
|
const canonical = createTask({ id: "FN-001", column: "todo" });
|
||||||
const task = createTask();
|
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 () => {
|
it("does not re-pause a same-canonical Keep acknowledgement after marker reprocessing", async () => {
|
||||||
const canonical = createTask({ id: "FN-001", column: "todo" });
|
const canonical = createTask({ id: "FN-001", column: "todo" });
|
||||||
const task = createTask({
|
const task = createTask({
|
||||||
|
|||||||
@@ -289,6 +289,14 @@ export async function isUnplannedForExecution(store: TaskStore, task: Task, ir:
|
|||||||
*/
|
*/
|
||||||
if (task.status === "needs-replan") return true;
|
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):
|
FNXC:WorkflowScheduling 2026-07-19-02:10 (U4):
|
||||||
Gate the bootstrap-stub check on the TRAIT, not the literal "todo" id. An
|
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
|
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.
|
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);
|
return isUnplannedSeedPrompt(promptContent, task.id, task.title, task.description);
|
||||||
} catch {
|
} catch {
|
||||||
// Missing prompt is handled by filesystem validation elsewhere; do not block on it here.
|
// 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 { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||||
import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js";
|
import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js";
|
||||||
import { emitWorkflowLifecycleEvent } from "@fusion/core";
|
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 {
|
import {
|
||||||
BLOCKED_THRASH_LIMIT,
|
BLOCKED_THRASH_LIMIT,
|
||||||
buildExternalBlockMetadataPatch,
|
buildExternalBlockMetadataPatch,
|
||||||
@@ -12688,9 +12688,10 @@ export class TaskExecutor {
|
|||||||
? this.store.getTasksDir()
|
? this.store.getTasksDir()
|
||||||
: join(this.rootDir, ".fusion", "tasks");
|
: join(this.rootDir, ".fusion", "tasks");
|
||||||
const promptContent = await readFile(getPromptPath(tasksDir, live.id), "utf-8").catch(() => "");
|
const promptContent = await readFile(getPromptPath(tasksDir, live.id), "utf-8").catch(() => "");
|
||||||
const redirectReason = nonExecutableDuplicateRedirectReason(promptContent);
|
const redirectReason = nonExecutableDuplicateRedirectReason(promptContent, live.title);
|
||||||
if (redirectReason) {
|
if (redirectReason) {
|
||||||
const marker = parseExplicitDuplicateMarker(promptContent);
|
const duplicateResolution = resolveExplicitDuplicateMarker(promptContent, live.title);
|
||||||
|
const marker = duplicateResolution.marker;
|
||||||
const replanColumn = await resolveReplanTargetColumn(this.store, live.id);
|
const replanColumn = await resolveReplanTargetColumn(this.store, live.id);
|
||||||
await moveTaskToReplanColumn(this.store, { id: live.id, column: live.column }, replanColumn);
|
await moveTaskToReplanColumn(this.store, { id: live.id, column: live.column }, replanColumn);
|
||||||
await this.store.updateTask(live.id, {
|
await this.store.updateTask(live.id, {
|
||||||
@@ -12698,8 +12699,8 @@ export class TaskExecutor {
|
|||||||
error: null,
|
error: null,
|
||||||
}, this.getRunContextFor(live.id));
|
}, this.getRunContextFor(live.id));
|
||||||
const feedback = marker
|
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 duplicate redirect (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 conflicting duplicate redirects (${redirectReason}). Correct the title or PROMPT.md before writing a full plan body.`;
|
||||||
await this.store.logEntry(
|
await this.store.logEntry(
|
||||||
live.id,
|
live.id,
|
||||||
"AI spec revision requested",
|
"AI spec revision requested",
|
||||||
|
|||||||
@@ -1511,7 +1511,16 @@ export class Scheduler {
|
|||||||
* @param id - The task ID to validate
|
* @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
|
* @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") {
|
if (typeof this.store.getTasksDir !== "function") {
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowScheduling 2026-06-23-11:38:
|
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
|
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.
|
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) {
|
if (duplicateOnly) {
|
||||||
return { valid: false, reason: duplicateOnly };
|
return { valid: false, reason: duplicateOnly };
|
||||||
}
|
}
|
||||||
@@ -2452,7 +2461,7 @@ export class Scheduler {
|
|||||||
FNXC:WorkflowScheduling 2026-06-23-11:12:
|
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.
|
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) {
|
if (!validation.valid) {
|
||||||
schedulerLog.warn(`Task ${task.id} filesystem validation failed: ${validation.reason}`);
|
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 { readFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
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,
|
resolveNearDuplicateCanonicalFlags,
|
||||||
LEGACY_COLUMN_IDS_BY_ROLE,
|
LEGACY_COLUMN_IDS_BY_ROLE,
|
||||||
TERMINAL_ROLES,
|
TERMINAL_ROLES,
|
||||||
@@ -7033,16 +7033,29 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
|||||||
without a real PROMPT. Drop a still-present DUPLICATE marker file when present.
|
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");
|
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 {
|
try {
|
||||||
const written = readFileSync(promptPath, "utf-8");
|
rmSync(promptPath, { force: true });
|
||||||
if (parseExplicitDuplicateMarker(written)) {
|
|
||||||
rmSync(promptPath, { force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort marker removal; status write still proceeds
|
// 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));
|
await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalId));
|
||||||
if (typeof this.store.logEntry === "function") {
|
if (typeof this.store.logEntry === "function") {
|
||||||
await Promise.resolve(this.store.logEntry(
|
await Promise.resolve(this.store.logEntry(
|
||||||
@@ -14440,15 +14453,13 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
|||||||
for (const task of candidates) {
|
for (const task of candidates) {
|
||||||
try {
|
try {
|
||||||
const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
const promptPath = join(this.options.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
|
||||||
if (!existsSync(promptPath)) {
|
const written = existsSync(promptPath) ? readFileSync(promptPath, "utf-8") : "";
|
||||||
continue;
|
const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
|
||||||
}
|
// A conflict has no safe canonical target; leave it for planning/operator correction.
|
||||||
|
if (!duplicateResolution.marker || duplicateResolution.conflict) {
|
||||||
const written = readFileSync(promptPath, "utf-8");
|
|
||||||
const marker = parseExplicitDuplicateMarker(written);
|
|
||||||
if (!marker) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const marker = duplicateResolution.marker;
|
||||||
if (processedMarkers >= 50) {
|
if (processedMarkers >= 50) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -14475,7 +14486,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
|||||||
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
|
const canonicalFlags = await resolveNearDuplicateCanonicalFlags(this.store, canonicalTask);
|
||||||
if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
|
if (!canonicalTask || isNearDuplicateCanonicalInactive(canonicalTask, canonicalFlags)) {
|
||||||
if (canClearInactiveMarker) {
|
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"
|
const priorClearCount = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number"
|
||||||
? task.sourceMetadata.duplicateMarkerClearCount
|
? task.sourceMetadata.duplicateMarkerClearCount
|
||||||
: 0;
|
: 0;
|
||||||
@@ -14502,7 +14517,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
|||||||
*/
|
*/
|
||||||
if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) {
|
if (resolution === "prompt" && isTriageDuplicateKeepAcknowledged(task.sourceMetadata, canonicalTask.id)) {
|
||||||
if (canClearInactiveMarker) {
|
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"
|
const priorKeepClears = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number"
|
||||||
? task.sourceMetadata.duplicateMarkerClearCount
|
? task.sourceMetadata.duplicateMarkerClearCount
|
||||||
: 0;
|
: 0;
|
||||||
@@ -14532,7 +14551,11 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
|
|||||||
await flagTriageDuplicate(this.store, task.id, canonicalTask.id);
|
await flagTriageDuplicate(this.store, task.id, canonicalTask.id);
|
||||||
await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null });
|
await this.store.updateTask(task.id, { paused: true, pausedReason: "duplicate-decision-required", status: null });
|
||||||
} else {
|
} 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));
|
await this.store.updateTask(task.id, buildMarkerClearedReplanTaskPatch(canonicalTask.id));
|
||||||
if (typeof this.store.logEntry === "function") {
|
if (typeof this.store.logEntry === "function") {
|
||||||
await Promise.resolve(this.store.logEntry(
|
await Promise.resolve(this.store.logEntry(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
isUnplannedSeedPrompt,
|
isUnplannedSeedPrompt,
|
||||||
isTaskAwaitingPlanning,
|
isTaskAwaitingPlanning,
|
||||||
getTaskDuplicateLineage,
|
getTaskDuplicateLineage,
|
||||||
parseExplicitDuplicateMarker,
|
resolveExplicitDuplicateMarker,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
buildPlanningDuplicatePolicyInstruction,
|
buildPlanningDuplicatePolicyInstruction,
|
||||||
builtinSeamPrompt,
|
builtinSeamPrompt,
|
||||||
@@ -1427,7 +1427,7 @@ export class TriageProcessor {
|
|||||||
to flag/delete/clear in finalizeApprovedTask. Requiring step headings for those markers
|
to flag/delete/clear in finalizeApprovedTask. Requiring step headings for those markers
|
||||||
withheld recovery forever (empty steps) so the marker path never ran.
|
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 workflow = await resolveWorkflowIrForTask(this.store, task.id).catch(() => undefined);
|
||||||
const requiresPromptImplementationSteps = workflow?.nodes.some((node) =>
|
const requiresPromptImplementationSteps = workflow?.nodes.some((node) =>
|
||||||
node.kind === "parse-steps"
|
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> {
|
async specifyTask(task: Task): Promise<void> {
|
||||||
/*
|
/*
|
||||||
FNXC:TriageStuckKill 2026-07-18-21:05:
|
FNXC:TriageStuckKill 2026-07-18-21:05:
|
||||||
@@ -2455,6 +2494,12 @@ export class TriageProcessor {
|
|||||||
return;
|
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 (await this.flagImportNearDuplicateBeforePlanning(task)) {
|
||||||
if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release();
|
if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release();
|
||||||
return;
|
return;
|
||||||
@@ -4251,12 +4296,12 @@ export class TriageProcessor {
|
|||||||
report: PlanningHandoffReport = { outcome: "parked" },
|
report: PlanningHandoffReport = { outcome: "parked" },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
|
const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
|
||||||
if (!explicitDuplicateMarker) {
|
if (!duplicateResolution.marker || duplicateResolution.conflict) {
|
||||||
return false;
|
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.
|
// A transient lookup failure must still fail open; only a genuine missing row is inactive.
|
||||||
const canonicalTask = await this.store.getTask(canonicalId);
|
const canonicalTask = await this.store.getTask(canonicalId);
|
||||||
if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) {
|
if (canonicalTask?.id.toLowerCase() === task.id.toLowerCase()) {
|
||||||
@@ -4446,10 +4491,23 @@ export class TriageProcessor {
|
|||||||
task: Task,
|
task: Task,
|
||||||
canonicalId: string,
|
canonicalId: string,
|
||||||
feedback: string,
|
feedback: string,
|
||||||
options?: { exhausted?: boolean; priorClearCount?: number },
|
options?: { exhausted?: boolean; priorClearCount?: number; source?: "prompt" | "title" },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!await this.runIfStillPlanningUnderTaskLock(task, async () => {
|
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;
|
})) return false;
|
||||||
|
|
||||||
const priorClearCount = options?.priorClearCount ?? 0;
|
const priorClearCount = options?.priorClearCount ?? 0;
|
||||||
@@ -4494,11 +4552,21 @@ export class TriageProcessor {
|
|||||||
report: PlanningHandoffReport = { outcome: "parked" },
|
report: PlanningHandoffReport = { outcome: "parked" },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let written = writtenInput;
|
let written = writtenInput;
|
||||||
// FNXC:WorkflowArtifacts 2026-07-21-17:00: Confirm the authoritative plan
|
const duplicateResolution = resolveExplicitDuplicateMarker(written, task.title);
|
||||||
// exists before persisting any dependencies, steps, metadata, or review state
|
if (duplicateResolution.conflict) {
|
||||||
// derived from it; a missing plan must leave no partially accepted projection.
|
/*
|
||||||
if (await this.recoverMissingPromptBeforeRelease(task)) return;
|
FNXC:DuplicateIntake 2026-08-09-01:02:
|
||||||
const explicitDuplicateMarker = parseExplicitDuplicateMarker(written);
|
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:
|
* FNXC:DuplicateIntake 2026-07-16-13:00:
|
||||||
@@ -4507,6 +4575,7 @@ export class TriageProcessor {
|
|||||||
*/
|
*/
|
||||||
if (explicitDuplicateMarker) {
|
if (explicitDuplicateMarker) {
|
||||||
const canonicalId = explicitDuplicateMarker.canonicalId;
|
const canonicalId = explicitDuplicateMarker.canonicalId;
|
||||||
|
const duplicateSource = duplicateResolution.source ?? "prompt";
|
||||||
const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
|
const canonicalTask = await this.store.getTask(canonicalId).catch(() => null);
|
||||||
const canClearInactiveMarker = task.userPaused !== true
|
const canClearInactiveMarker = task.userPaused !== true
|
||||||
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
|
&& (task.paused !== true || task.pausedReason === "duplicate-decision-required")
|
||||||
@@ -4540,7 +4609,7 @@ export class TriageProcessor {
|
|||||||
task,
|
task,
|
||||||
canonicalId,
|
canonicalId,
|
||||||
buildInactiveDuplicateClearFeedback(canonicalId),
|
buildInactiveDuplicateClearFeedback(canonicalId),
|
||||||
{ exhausted: false, priorClearCount },
|
{ exhausted: false, priorClearCount, source: duplicateSource },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -4561,7 +4630,7 @@ export class TriageProcessor {
|
|||||||
task,
|
task,
|
||||||
canonicalId,
|
canonicalId,
|
||||||
buildKeepDuplicateClearFeedback(canonicalId),
|
buildKeepDuplicateClearFeedback(canonicalId),
|
||||||
{ exhausted: priorClearCount >= 1, priorClearCount },
|
{ exhausted: priorClearCount >= 1, priorClearCount, source: duplicateSource },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -4598,6 +4667,7 @@ export class TriageProcessor {
|
|||||||
task,
|
task,
|
||||||
canonicalId,
|
canonicalId,
|
||||||
buildKeepDuplicateClearFeedback(canonicalId),
|
buildKeepDuplicateClearFeedback(canonicalId),
|
||||||
|
{ source: duplicateSource },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user