fix(FN-8600): recover a duplicate verdict the planner reported in its reply

The prompt fix stops planners writing the verdict in prose, but it relies on
every model reading one sentence correctly. This closes the hole underneath it.

When the finalize read finds no spec at all, the planner's streamed reply is
searched for a line that is exactly `DUPLICATE: FN-NNNN`. If found, the engine
writes the canonical marker file and continues — so marker parsing, keep/delete
resolution, and the sourceMetadata.nearDuplicateOf that renders the operator's
decision all run on the unchanged file contract rather than a second code path
that could drift from it.

Deliberately narrow. The marker must occupy a whole line, only the first counts,
and recovery is gated on the plan being genuinely absent — a planner that wrote
a real spec is never overridden by something it said in passing. The text tail
is bounded because the verdict lands in the closing summary, and it tees off
onText rather than reading AgentLogger, whose buffer is flushed on a timer.

Verified both directions: the tests fail without the recovery block, and the
"wrote a real spec while mentioning a marker" case keeps its spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-26 10:23:00 -07:00
parent a00f2633ce
commit 71279ed042
7 changed files with 321 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: A duplicate task is now parked for your keep-or-delete decision even if the planner only says so in its reply.
category: fix
dev: New `parseDuplicateMarkerFromSessionText` (line-anchored, first-match-only) plus a bounded tail of the planner's streamed text in `TriageProcessor.specifyTask`. When the finalize read finds no spec, a duplicate verdict recovered from the reply is written out as the canonical `DUPLICATE: FN-NNNN` marker file, so marker parsing, keep/delete resolution, and the `sourceMetadata.nearDuplicateOf` the dashboard decision renders from all run on the unchanged file contract. Gated on an absent plan, so a planner that wrote a real spec is never overridden by prose.

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseExplicitDuplicateMarker } from "../explicit-duplicate-marker.js";
import { parseExplicitDuplicateMarker, parseDuplicateMarkerFromSessionText } from "../explicit-duplicate-marker.js";
const FULL_PROMPT = `# Task: FN-5211 - Example
@@ -68,3 +68,47 @@ describe("parseExplicitDuplicateMarker", () => {
expect(parseExplicitDuplicateMarker("DUPLICATE: NOT-1234")).toBeNull();
});
});
/*
FNXC:DuplicateIntake 2026-07-26-10:40:
Recovery parser for a duplicate verdict announced in the planner's reply instead of written to
PROMPT.md (FN-8600). Narrowness is the whole point: it must catch the real shape without letting a
passing mention hijack a task that has a real spec.
*/
describe("parseDuplicateMarkerFromSessionText", () => {
it("recovers the verdict from the exact shape FN-8600 produced", () => {
const reply = [
"DUPLICATE: FN-8595",
"",
"FN-8595 (done) already delivered the mobile favorites section including the per-row star",
"toggle to favorite/unfavorite projects from mobile. No new PROMPT.md written.",
].join("\n");
expect(parseDuplicateMarkerFromSessionText(reply)).toEqual({ canonicalId: "FN-8595" });
});
it("finds the marker when it closes the reply rather than opening it", () => {
const reply = "Checked fn_task_search and fn_task_show.\n\nDUPLICATE: FN-42\n";
expect(parseDuplicateMarkerFromSessionText(reply)).toEqual({ canonicalId: "FN-42" });
});
it("ignores a marker mentioned inside a sentence", () => {
const reply = "I considered whether to emit DUPLICATE: FN-1 here but the scope differs, so I wrote a spec.";
expect(parseDuplicateMarkerFromSessionText(reply)).toBeNull();
});
it("takes only the first marker when several ids are listed", () => {
const reply = "DUPLICATE: FN-1\nDUPLICATE: FN-2\n";
expect(parseDuplicateMarkerFromSessionText(reply)).toEqual({ canonicalId: "FN-1" });
});
it("tolerates backtick and bold wrappers and lowercase, like the file parser", () => {
expect(parseDuplicateMarkerFromSessionText("`duplicate: fn-7`")).toEqual({ canonicalId: "FN-7" });
expect(parseDuplicateMarkerFromSessionText("**DUPLICATE: FN-8**")).toEqual({ canonicalId: "FN-8" });
});
it("returns null for empty or marker-free text", () => {
expect(parseDuplicateMarkerFromSessionText("")).toBeNull();
expect(parseDuplicateMarkerFromSessionText(" \n ")).toBeNull();
expect(parseDuplicateMarkerFromSessionText("Wrote the spec; not a duplicate.")).toBeNull();
});
});

View File

@@ -50,3 +50,32 @@ export function parseExplicitDuplicateMarker(content: string): ExplicitDuplicate
canonicalId: match[1].toUpperCase(),
};
}
/*
FNXC:DuplicateIntake 2026-07-26-10:40:
Recovery parser for a duplicate verdict the planner announced in its REPLY instead of writing it to
PROMPT.md. Observed on FN-8600 (2026-07-26): the planner correctly identified the duplicate, said
"DUPLICATE: FN-8595" followed by its reasoning, and explicitly declined to write a spec file — so the
engine, which reads the verdict only from PROMPT.md's contents, saw a planner that produced no plan.
The card then failed deterministic validation, retried, terminalized, and was re-planned in a loop,
never reaching the branch that records the operator's keep-or-delete decision.
Deliberately NARROWER than a "find the word anywhere" scan, because session text is prose and a
planner may legitimately discuss another task's duplicate marker while writing a real spec:
- the marker must occupy an ENTIRE line by itself (same shape the file contract demands), so a
mention inside a sentence never triggers it;
- only the FIRST such line counts — a planner listing several ids has not made a single decision;
- callers must gate on "no plan was written", so this can never override a real spec.
*/
export function parseDuplicateMarkerFromSessionText(text: string): ExplicitDuplicateMarker | null {
if (!text.trim()) return null;
for (const rawLine of text.split("\n")) {
const candidate = stripSingleWrapper(rawLine.trim());
const match = candidate.match(/^DUPLICATE:\s*(FN-\d+)\s*$/i);
if (match) {
return { canonicalId: match[1].toUpperCase() };
}
}
return null;
}

View File

@@ -718,6 +718,7 @@ export {
} from "./near-duplicate.js";
export { getTaskDuplicateLineage } from "./duplicate-lineage.js";
export {
parseDuplicateMarkerFromSessionText,
parseExplicitDuplicateMarker,
type ExplicitDuplicateMarker,
} from "./explicit-duplicate-marker.js";

View File

@@ -812,6 +812,7 @@ export {
} from "./near-duplicate.js";
export { getTaskDuplicateLineage } from "./duplicate-lineage.js";
export {
parseDuplicateMarkerFromSessionText,
parseExplicitDuplicateMarker,
type ExplicitDuplicateMarker,
} from "./explicit-duplicate-marker.js";

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Settings, Task, TaskDetail, TaskStore } from "@fusion/core";
import { TriageProcessor } from "../triage.js";
/*
FNXC:DuplicateIntake 2026-07-26-10:40:
Regression suite for the FN-8600 duplicate-verdict loop.
Original symptom: the planner correctly identified FN-8600 as a duplicate of FN-8595, replied
"DUPLICATE: FN-8595 ... No new PROMPT.md written", and wrote no spec file. The engine reads the
verdict only from PROMPT.md's contents, so it saw a planner that produced no plan: deterministic
validation failed as "PROMPT.md file not found or empty", the task retried, terminalized to failed,
emitted a task-wedge mail, was recovered to todo by self-healing, and re-planned — three full Opus
cycles, with no keep-or-delete decision ever surfaced because `sourceMetadata.nearDuplicateOf` is
only written on the branch that parses the file.
The invariant under test is "a duplicate verdict the planner actually reached is recorded", not
merely "the parser works": recovery must persist the canonical marker file so every downstream
consumer (marker parse, keep/delete resolution, the metadata the dashboard decision renders from)
runs on the same contract, and must never override a planner that produced a real spec.
*/
const { mockCreateFnAgent, mockPromptWithFallback } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
mockPromptWithFallback: vi.fn(),
}));
vi.mock("../reviewer.js", () => ({ reviewStep: vi.fn() }));
vi.mock("../pi.js", () => {
class ModelFallbackExhaustedError extends Error {}
return {
ModelFallbackExhaustedError,
createFnAgent: mockCreateFnAgent,
describeModel: vi.fn().mockReturnValue("mock-model"),
formatModelMarkerDetails: vi.fn((model: string) => model),
promptWithFallback: mockPromptWithFallback,
};
});
vi.mock("@fusion/core", async (importOriginal) => {
const { createEngineCoreMock } = await import("../test/mockCore.js");
const original = await importOriginal<typeof import("@fusion/core")>();
return createEngineCoreMock(() => Promise.resolve(original));
});
const DUPLICATE_REPLY = [
"DUPLICATE: FN-8595",
"",
"FN-8595 (done) already delivered the mobile favorites section including the per-row star toggle",
"to favorite/unfavorite projects from mobile. No new PROMPT.md written.",
].join("\n");
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-8600",
description: "Add ability to favorite projects on mobile",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-07-26T15:37:52.786Z",
updatedAt: "2026-07-26T15:37:52.786Z",
...overrides,
};
}
function createStore(task: Task): TaskStore {
return {
getTask: vi.fn().mockImplementation(async (id: string) => {
if (id === task.id) return { ...task, prompt: "", attachments: [], comments: [] } as TaskDetail;
// The canonical the planner points at: a real, active task.
return { ...createTask({ id, column: "done" }), prompt: "", attachments: [], comments: [] } as TaskDetail;
}),
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 12, maxWorktrees: 4, pollIntervalMs: 600_000,
groupOverlappingFiles: false, autoMerge: true,
} as Settings),
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
recordActivity: vi.fn().mockResolvedValue(undefined),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn(), createTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(),
updateSettings: vi.fn(), getAgentLogs: vi.fn().mockResolvedValue([]), addSteeringComment: vi.fn(),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
on: vi.fn(), emit: vi.fn(),
} as unknown as TaskStore;
}
/** Streams `reply` as the planner's visible text, writing `specBody` to PROMPT.md when given. */
function stubPlanner(rootDir: string, taskId: string, reply: string, specBody?: string): void {
mockCreateFnAgent.mockImplementationOnce(async (opts: { onText?: (t: string) => void }) => ({
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
__onText: opts.onText,
},
}));
mockPromptWithFallback.mockImplementationOnce(async (session: { __onText?: (t: string) => void }) => {
// Stream in chunks, as a real runtime does — recovery must not depend on one whole-text callback.
for (const chunk of reply.match(/[\s\S]{1,17}/g) ?? []) session.__onText?.(chunk);
if (specBody !== undefined) {
const { writeFile } = await import("node:fs/promises");
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), specBody, "utf-8");
}
});
}
describe("duplicate verdict reported in the planner's reply (FN-8600)", () => {
let rootDir: string;
beforeEach(async () => {
vi.clearAllMocks();
rootDir = await mkdtemp(join(tmpdir(), "fn8600-dupe-"));
await mkdir(join(rootDir, ".fusion", "tasks", "FN-8600"), { recursive: true });
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
});
it("persists the canonical marker file when the planner reported the duplicate in prose only", async () => {
const task = createTask();
stubPlanner(rootDir, task.id, DUPLICATE_REPLY);
await new TriageProcessor(createStore(task), rootDir, {
acquirePlanningWorktree: async () => null,
}).specifyTask(task);
const promptPath = join(rootDir, ".fusion", "tasks", "FN-8600", "PROMPT.md");
expect(existsSync(promptPath)).toBe(true);
// Recovery must produce the exact file contract, so downstream consumers are unchanged.
expect((await readFile(promptPath, "utf-8")).trim()).toBe("DUPLICATE: FN-8595");
});
it("does not let a prose mention override a planner that wrote a real spec", async () => {
const task = createTask({ id: "FN-8600" });
const realSpec = "# FN-8600\n\n## Mission\nShip the thing.\n";
stubPlanner(
rootDir,
task.id,
"I checked whether to emit DUPLICATE: FN-8595 but the scope differs, so I wrote a spec.",
realSpec,
);
await new TriageProcessor(createStore(task), rootDir, {
acquirePlanningWorktree: async () => null,
}).specifyTask(task);
const written = await readFile(join(rootDir, ".fusion", "tasks", "FN-8600", "PROMPT.md"), "utf-8");
expect(written).toBe(realSpec);
expect(written).not.toContain("DUPLICATE:");
});
});

View File

@@ -1401,6 +1401,14 @@ export class TriageProcessor {
/** Coalescing window for requestImmediatePoll, so a multi-card drag causes one poll, not N. */
private static readonly NUDGE_DEBOUNCE_MS = 150;
/**
* FNXC:DuplicateIntake 2026-07-26-10:40:
* How much of the planner's visible reply is retained for duplicate-verdict recovery. The marker
* convention places the verdict in the closing summary, so a tail is sufficient and keeps a long
* planning run from accumulating every streamed token in memory.
*/
private static readonly SESSION_TEXT_TAIL_CHARS = 4000;
private async poll(): Promise<void> {
if (!this.running) return;
if (this.polling) return;
@@ -1697,6 +1705,13 @@ export class TriageProcessor {
*/
let registeredPlanningPath: string | null = null;
/*
FNXC:DuplicateIntake 2026-07-26-10:40:
Bounded tail of the planner's visible reply, used only to recover a duplicate verdict the planner
announced in prose instead of writing to PROMPT.md (FN-8600).
*/
let sessionTextTail = "";
planLog.log(
`Specifying ${task.id}: ${task.title || task.description.slice(0, 60)}`,
);
@@ -2062,7 +2077,20 @@ export class TriageProcessor {
systemPromptLayers: triageLayers,
tools: "coding",
customTools,
onText: agentLogger.onText,
onText: (text: string) => {
/*
FNXC:DuplicateIntake 2026-07-26-10:40:
Tee the planner's visible text into a bounded tail so a duplicate verdict announced in the
REPLY (rather than written to PROMPT.md) is still recoverable at finalize — see the
recovery block below. AgentLogger flushes and clears its own buffer on a timer, so it
cannot be read back for this; this tail is independent of it and never replaces it.
Bounded to the last SESSION_TEXT_TAIL_CHARS characters because the marker convention puts
the verdict in the closing summary, and an unbounded accumulator would grow with every
streamed token of a long planning run.
*/
sessionTextTail = `${sessionTextTail}${text}`.slice(-TriageProcessor.SESSION_TEXT_TAIL_CHARS);
agentLogger.onText(text);
},
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,
onToolEnd: agentLogger.onToolEnd,
@@ -2320,7 +2348,7 @@ export class TriageProcessor {
).catch(() => undefined);
}
const written = await readFile(
let written = await readFile(
join(this.rootDir, promptPath),
"utf-8",
).catch((err: unknown) => {
@@ -2329,6 +2357,46 @@ export class TriageProcessor {
return "";
});
/*
FNXC:DuplicateIntake 2026-07-26-10:40:
Recover a duplicate verdict the planner reported in its REPLY instead of writing it to
PROMPT.md. FN-8600: the planner found the duplicate, said `DUPLICATE: FN-8595`, and stated
"No new PROMPT.md written" — a reasonable reading of an instruction that said not to write a
spec. The engine reads the verdict only from the file, so it saw no plan at all, failed
deterministic validation, retried, terminalized, self-healed to todo, and re-planned in a
loop, never recording the operator's keep-or-delete decision.
Recovery WRITES the canonical marker file rather than routing the verdict through a second
code path, so everything downstream — marker parse, keep/delete resolution, the
`nearDuplicateOf` metadata the dashboard decision renders from — runs unchanged and cannot
drift from the file-based contract.
Gated on a genuinely absent plan: only when the file read produced nothing does prose get a
vote. A planner that wrote a real spec is never second-guessed by something it said, and the
line-anchored parser ignores a marker merely mentioned mid-sentence.
*/
if (!written.trim()) {
const recoveredMarker = fusionCore.parseDuplicateMarkerFromSessionText(sessionTextTail);
if (recoveredMarker) {
const markerBody = `DUPLICATE: ${recoveredMarker.canonicalId}\n`;
const recovered = await writeFile(join(this.rootDir, promptPath), markerBody, "utf-8")
.then(() => true)
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to persist recovered duplicate marker: ${msg}`);
return false;
});
if (recovered) {
written = markerBody;
planLog.log(`${task.id}: recovered duplicate verdict ${recoveredMarker.canonicalId} from the planner's reply (no PROMPT.md was written)`);
await this.store.logEntry(
task.id,
`Recovered duplicate verdict from the planning reply — the planner reported ${recoveredMarker.canonicalId} without writing PROMPT.md`,
).catch(() => undefined);
}
}
}
// FN-5220: planning agents that emit a `DUPLICATE: FN-NNNN` redirect
// short-circuit normal spec finalization.
if (await this.tryFinalizeExplicitDuplicateMarker(task, written, settings, {