diff --git a/.changeset/duplicate-verdict-session-recovery.md b/.changeset/duplicate-verdict-session-recovery.md new file mode 100644 index 0000000000..7927c8faa3 --- /dev/null +++ b/.changeset/duplicate-verdict-session-recovery.md @@ -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. diff --git a/packages/core/src/__tests__/explicit-duplicate-marker.test.ts b/packages/core/src/__tests__/explicit-duplicate-marker.test.ts index ce19ba283f..371a83a292 100644 --- a/packages/core/src/__tests__/explicit-duplicate-marker.test.ts +++ b/packages/core/src/__tests__/explicit-duplicate-marker.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/explicit-duplicate-marker.ts b/packages/core/src/explicit-duplicate-marker.ts index 48d4730cf2..c781265d1a 100644 --- a/packages/core/src/explicit-duplicate-marker.ts +++ b/packages/core/src/explicit-duplicate-marker.ts @@ -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; +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 62429dfada..4d98c70bb8 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -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"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 43d713a5f3..73f16cf9a3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/engine/src/__tests__/triage-duplicate-verdict-session-recovery.test.ts b/packages/engine/src/__tests__/triage-duplicate-verdict-session-recovery.test.ts new file mode 100644 index 0000000000..1bd3f98a07 --- /dev/null +++ b/packages/engine/src/__tests__/triage-duplicate-verdict-session-recovery.test.ts @@ -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(); + 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 { + 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:"); + }); +}); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 2f46a81ab1..bfc8acdede 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 { 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, {