From 1d1a7fedab8e564a8eba299adc6f4ef1a7485dae Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 4 Aug 2026 10:52:12 -0700 Subject: [PATCH] fix(FN-8785): ignore completed duplicate history Fusion-Task-Id: FN-8785 --- .changeset/fn-8785-user-duplicate-history.md | 7 ++ .../core/src/__tests__/agent-prompts.test.ts | 17 +++++ .../src/__tests__/duplicate-guard.test.ts | 62 +++++++++++++++++ packages/core/src/agents/agent-prompts.ts | 16 +++-- .../core/src/duplicates/duplicate-guard.ts | 21 +++++- .../near-duplicate-canonical-flags.ts | 15 ++++ packages/core/src/index.gate.ts | 2 + packages/core/src/index.ts | 2 + .../routes-tasks-deterministic-dedup.test.ts | 58 ++++++++++++++++ .../routes-tasks-duplicate-check.test.ts | 25 ++++++- ...es-tasks-explicit-duplicate-marker.test.ts | 36 ++++++++++ .../routes-tasks-near-duplicate.test.ts | 34 ++++++++- .../routes/register-task-workflow-routes.ts | 57 +++++++++++++-- .../__tests__/agent-tools-delegation.test.ts | 34 +++++++++ ...ent-tools-renamed-terminal-columns.test.ts | 11 +-- .../explicit-duplicate-marker-sweep.test.ts | 29 +++++++- ...triage-duplicate-search-regression.test.ts | 15 ++-- .../triage-explicit-duplicate-marker.test.ts | 69 ++++++++++++++++--- ...iage-planning-prompt-single-source.test.ts | 33 +++++++++ packages/engine/src/__tests__/triage.test.ts | 31 ++++----- packages/engine/src/agent-tools.ts | 40 +++-------- packages/engine/src/duplicate-marker-clear.ts | 5 +- packages/engine/src/self-healing.ts | 37 +--------- packages/engine/src/triage.ts | 51 ++++---------- 24 files changed, 535 insertions(+), 172 deletions(-) create mode 100644 .changeset/fn-8785-user-duplicate-history.md create mode 100644 packages/core/src/duplicates/near-duplicate-canonical-flags.ts diff --git a/.changeset/fn-8785-user-duplicate-history.md b/.changeset/fn-8785-user-duplicate-history.md new file mode 100644 index 0000000000..000aded414 --- /dev/null +++ b/.changeset/fn-8785-user-duplicate-history.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Ignore completed and archived tasks during duplicate detection while protecting active work. +category: fix +dev: Applies active-only duplicate matching consistently across task intake, planning, and recovery. diff --git a/packages/core/src/__tests__/agent-prompts.test.ts b/packages/core/src/__tests__/agent-prompts.test.ts index cf65dd89a3..eec83c3a95 100644 --- a/packages/core/src/__tests__/agent-prompts.test.ts +++ b/packages/core/src/__tests__/agent-prompts.test.ts @@ -9,6 +9,7 @@ import { getTemplatesForRole, FUSION_RUNTIME_SELF_AWARENESS, TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, + buildPlanningDuplicatePolicyInstruction, } from "../agents/agent-prompts.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../workflows/builtin-coding-workflow-ir.js"; import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../workflows/builtin-workflow-prompts.js"; @@ -22,6 +23,22 @@ import type { WorkflowIr } from "../workflows/workflow-ir-types.js"; // --------------------------------------------------------------------------- describe("resolveAgentPrompt", () => { + it("treats completed matches as history for user-authored tasks", () => { + const instruction = buildPlanningDuplicatePolicyInstruction(); + + expect(instruction).toContain("Only active tasks can be duplicate blockers"); + expect(instruction).toContain("done or archived"); + expect(instruction).toContain("write a new plan"); + }); + + it("treats completed matches as history for programmatic tasks too", () => { + const instruction = buildPlanningDuplicatePolicyInstruction(); + + expect(instruction).toContain("Only active tasks can be duplicate blockers"); + expect(instruction).toContain("done or archived"); + expect(instruction).toContain("write a new plan"); + }); + it("returns the correct built-in prompt for executor when no config provided", () => { const result = resolveAgentPrompt("executor"); expect(result).toBeTruthy(); diff --git a/packages/core/src/__tests__/duplicate-guard.test.ts b/packages/core/src/__tests__/duplicate-guard.test.ts index cb374a49a1..10ae131343 100644 --- a/packages/core/src/__tests__/duplicate-guard.test.ts +++ b/packages/core/src/__tests__/duplicate-guard.test.ts @@ -109,6 +109,54 @@ describe("runDeterministicDuplicateGuard", () => { result.releaseLock(); }); + it.each(["done", "archived"] as const)("ignores an exact match in %s", async (column) => { + const existing = mkTask({ + id: "FN-1", + title: INPUT.title, + description: INPUT.description, + column, + source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } }, + }); + const { store } = makeStore([existing]); + vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([existing]); + + const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" }); + + expect(result.action).toBe("proceed"); + result.releaseLock(); + }); + + it("ignores an exact match in a renamed complete column", async () => { + const existing = mkTask({ + id: "FN-1", + title: INPUT.title, + description: INPUT.description, + column: "shipped", + source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } }, + }); + const { store } = makeStore([existing]); + vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([existing]); + const ir = { + version: "v2", id: "renamed-complete", name: "Renamed complete", + columns: [ + { id: "todo", name: "Todo", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, + ], + nodes: [{ id: "start", kind: "start", column: "todo" }], + edges: [], + }; + Object.assign(store, { + getTaskWorkflowSelectionAsync: vi.fn(async () => ({ workflowId: "renamed-complete", stepIds: [] })), + getTaskWorkflowSelection: vi.fn(() => ({ workflowId: "renamed-complete", stepIds: [] })), + getWorkflowDefinition: vi.fn(async () => ({ ir })), + }); + + const result = await runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" }); + + expect(result.action).toBe("proceed"); + result.releaseLock(); + }); + it("scopes exact duplicates to the creating parent task", async () => { const foreignSibling = mkTask({ id: "FN-1", @@ -246,6 +294,20 @@ describe("runDeterministicDuplicateGuard", () => { }); describe("reconcileDeterministicDuplicate", () => { + it("does not archive new work against a completed sibling", async () => { + const canonicalTs = new Date(Date.now() - 2_000).toISOString(); + const createdTs = new Date().toISOString(); + const completed = mkTask({ id: "FN-1", title: INPUT.title, description: INPUT.description, column: "done", createdAt: canonicalTs, updatedAt: canonicalTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } }); + const created = mkTask({ id: "FN-2", title: INPUT.title, description: INPUT.description, column: "todo", createdAt: createdTs, updatedAt: createdTs, source: { sourceType: "api", sourceMetadata: { contentFingerprint: "fp" } } }); + const { store } = makeStore([completed, created]); + vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([completed, created]); + + const result = await reconcileDeterministicDuplicate(store, { createdTask: created, fingerprint: "fp" }); + + expect(result).toEqual({ outcome: "kept", canonical: created }); + expect(store.moveTask).not.toHaveBeenCalled(); + }); + it("does not archive an identical task created by a different parent", async () => { const canonicalTs = new Date(Date.now() - 2_000).toISOString(); const createdTs = new Date().toISOString(); diff --git a/packages/core/src/agents/agent-prompts.ts b/packages/core/src/agents/agent-prompts.ts index 8a0234ee64..c678a39c70 100644 --- a/packages/core/src/agents/agent-prompts.ts +++ b/packages/core/src/agents/agent-prompts.ts @@ -279,7 +279,7 @@ Write a lean, executable PROMPT.md quickly. Preserve safety gates, but skip heav - Preserve required safety sections for bugs, workflow routing, forensic tasks, and decision-only work. ## Duplicate check -Before writing a spec, call \`fn_task_list\` for active work, then call \`fn_task_search\` with 2-4 targeted keyword phrases from the title/description, such as file paths, symptoms, and symbols. For any likely match in \`done\` or \`archived\`, call \`fn_task_show\` and inspect it before deciding. If an existing task covers the same work, do not write a spec — but still write PROMPT.md, with its entire contents being the single line \`DUPLICATE: {existing-task-id}\` and nothing else. That file is how the duplicate is recorded; announcing it only in your reply leaves no plan behind and re-plans the task in a loop. +Before writing a spec, call \`fn_task_list\` for active work, then call \`fn_task_search\` with \`includeDone: false\` and \`includeArchived: false\` for 2-4 targeted keyword phrases from the title/description, such as file paths, symptoms, and symbols. Do not search completed or archived work for duplicate candidates. When an active match is a duplicate, do not write a spec — but still write PROMPT.md, with its entire contents being the single line \`DUPLICATE: {existing-task-id}\` and nothing else. That file is how the duplicate is recorded; announcing it only in your reply leaves no plan behind and re-plans the task in a loop. ## Required PROMPT.md shape Write PROMPT.md with Original Description, Before → After Transformation, Mission, Dependencies, Context to Read First, File Scope, Steps, Documentation Requirements, Completion Criteria, Git Commit Convention, and Do NOT. Put \`## Original Description\` immediately after the title/\`Created\`/\`Size\` metadata with the operator's original task description copied **verbatim** (do not paraphrase). Put \`## Before → After Transformation\` next, before \`## Mission\`, with concise Before/After bullets: current state, target state, why it satisfies the user's request at a glance. In \`## Steps\`, every executable heading MUST use \`### Step N: \` (e.g. \`### Step 1: Preflight\`). Do not write bare \`### Preflight\` / \`### Implementation\` headings, and do not add review-level, triage subtask, or proactive subtask headings. @@ -548,9 +548,9 @@ Verified facts about this codebase's storage — cite these correctly so Plan Re - New Postgres migrations must be **registered explicitly** in \`packages/core/src/postgres/schema-applier.ts\` (version constant + bookkeeping check); a \`.sql\` file dropped in the migrations dir that is not wired there silently never runs. ## Duplicate check -Before writing a spec, first call \`fn_task_list\` to see active tasks, then call \`fn_task_search\` with 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names). -For any likely match in \`done\` or \`archived\`, call \`fn_task_show\` to inspect details before deciding. -If a task already covers the same work (even if worded differently), do not write a spec. +Before writing a spec, first call \`fn_task_list\` to see active tasks, then call \`fn_task_search\` with \`includeDone: false\` and \`includeArchived: false\` for 2-4 distinct keyword phrases from the task title and description (for example file paths, error symptoms, and symbol names). +Do not search completed or archived work for duplicate candidates. +If an actionable task already covers the same work (even if worded differently), do not write a spec. Instead you MUST still write PROMPT.md, with its ENTIRE contents being this one line and nothing else: \`DUPLICATE: {existing-task-id}\` Writing that file IS how you report the duplicate. Reporting it only in your reply is not recorded: @@ -581,7 +581,7 @@ Workflow policy can disable proactive oversized-task splitting for operators who ## Triage tools You have these extra tools during triage: - \`fn_task_list\` — list existing active tasks -- \`fn_task_search\` — keyword search across tasks, including done and archived tasks +- \`fn_task_search\` — keyword search across active tasks by default; history is opt-in for non-duplicate research - \`fn_task_show\` — inspect a task and its PROMPT.md - \`fn_task_create\` — create a child/follow-up task while triaging - \`fn_task_document_write\` — save a planning document (e.g., key="plan") @@ -884,6 +884,12 @@ the changes into the assigned worktree. - **NEVER kill processes on port 4040.** Port 4040 is the production dashboard. If you need to test server endpoints, start a server on a different port (\`--port 0\` for random). If port 4040 is occupied, use a different port — do NOT kill the occupant. Issue REVISE if the executor kills or attempts to kill processes on port 4040. `; +export function buildPlanningDuplicatePolicyInstruction(): string { + return `## Duplicate policy for this task + +Only active tasks can be duplicate blockers. A matching task in a done or archived column is historical evidence, not a duplicate verdict: inspect it for context, then write a new plan for the reported work or regression. Do not emit \`DUPLICATE: ...\` for completed or archived work.`; +} + /** * Base merger prompt text (without commit format instructions, which are * appended dynamically by the merger's buildMergeSystemPrompt function). diff --git a/packages/core/src/duplicates/duplicate-guard.ts b/packages/core/src/duplicates/duplicate-guard.ts index 4ac612effd..6cae3fab37 100644 --- a/packages/core/src/duplicates/duplicate-guard.ts +++ b/packages/core/src/duplicates/duplicate-guard.ts @@ -1,6 +1,8 @@ import type { Task } from "../types.js"; import type { TaskStore } from "../store.js"; import { computeContentFingerprint } from "./duplicate-detection.js"; +import { isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; +import { resolveNearDuplicateCanonicalFlags } from "./near-duplicate-canonical-flags.js"; import { resolveArchiveTargetForTask } from "../workflows/workflow-lifecycle-traits.js"; /* @@ -69,6 +71,19 @@ function matchesParentScope(task: Task, sourceParentTaskId?: string | null): boo return !sourceParentTaskId || task.sourceParentTaskId === sourceParentTaskId; } +async function findActiveDuplicate( + store: TaskStore, + candidates: readonly Task[], + predicate: (task: Task) => boolean, +): Promise { + for (const candidate of candidates) { + if (!predicate(candidate)) continue; + const flags = await resolveNearDuplicateCanonicalFlags(store, candidate); + if (!isNearDuplicateCanonicalInactive(candidate, flags)) return candidate; + } + return undefined; +} + export async function runDeterministicDuplicateGuard( store: TaskStore, input: { title?: string | null; description: string }, @@ -88,7 +103,7 @@ export async function runDeterministicDuplicateGuard( windowMs, includeArchived: false, }); - const deterministicConflict = deterministicMatches.find((match) => + const deterministicConflict = await findActiveDuplicate(store, deterministicMatches, (match) => matchesParentScope(match, opts?.sourceParentTaskId) && !acknowledged.has(match.id), ); if (deterministicConflict) { @@ -140,7 +155,7 @@ export async function runDeterministicDuplicateGuard( windowMs, includeArchived: false, }); - const deterministicConflict = deterministicMatches.find((match) => + const deterministicConflict = await findActiveDuplicate(store, deterministicMatches, (match) => matchesParentScope(match, opts.sourceParentTaskId) && !acknowledged.has(match.id), ); if (deterministicConflict) { @@ -179,7 +194,7 @@ export async function reconcileDeterministicDuplicate( includeArchived: false, }); - const olderSibling = siblings.find((sibling) => + const olderSibling = await findActiveDuplicate(store, siblings, (sibling) => sibling.id !== args.createdTask.id && sibling.createdAt < args.createdTask.createdAt && matchesParentScope(sibling, args.sourceParentTaskId), diff --git a/packages/core/src/duplicates/near-duplicate-canonical-flags.ts b/packages/core/src/duplicates/near-duplicate-canonical-flags.ts new file mode 100644 index 0000000000..6398f9c5fb --- /dev/null +++ b/packages/core/src/duplicates/near-duplicate-canonical-flags.ts @@ -0,0 +1,15 @@ +import type { TaskStore } from "../store.js"; +import { resolveColumnFlags } from "../workflows/trait-registry.js"; +import { resolveWorkflowIrForTask } from "../workflows/workflow-ir-resolver.js"; + +/** Resolve a canonical task's own terminal-role flags, failing soft to legacy column ids. */ +export async function resolveNearDuplicateCanonicalFlags( + store: TaskStore, + canonical: { id: string; column?: string | null } | null | undefined, +): Promise | undefined> { + if (!canonical?.column) return undefined; + const workflow = await resolveWorkflowIrForTask(store, canonical.id).catch(() => undefined); + if (!workflow || workflow.version !== "v2") return undefined; + const column = workflow.columns.find((candidate) => candidate.id === canonical.column); + return column ? resolveColumnFlags(column) : undefined; +} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 2b3b93d94b..4165e8645f 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -88,6 +88,7 @@ export type { PromptConditionEvaluationResult } from "./plugins/plugin-prompt-co export { computePlanApprovalFingerprint, isPlanReviewSatisfied, resolvePlanApprovalRequired } from "./planner/plan-approval.js"; export type { PlanApprovalMode } from "./planner/plan-approval.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./duplicates/near-duplicate-canonical.js"; +export { resolveNearDuplicateCanonicalFlags } from "./duplicates/near-duplicate-canonical-flags.js"; export type { NearDuplicateCanonicalState } from "./duplicates/near-duplicate-canonical.js"; export * from "./tasks/frontend-ux-policy.js"; export * from "./tasks/original-description-policy.js"; @@ -185,6 +186,7 @@ export * from "./mesh/mesh-task-replication.js"; export * from "./mesh/shared-mesh-state.js"; export { BUILTIN_AGENT_PROMPTS, + buildPlanningDuplicatePolicyInstruction, resolveAgentPrompt, buildTriageHeartbeatGuidance, buildConciseTriageHeartbeatGuidance, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fdf7221d8..895a831c41 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -96,6 +96,7 @@ export type { PromptConditionEvaluationResult } from "./plugins/plugin-prompt-co export { computePlanApprovalFingerprint, isPlanReviewSatisfied, resolvePlanApprovalRequired } from "./planner/plan-approval.js"; export type { PlanApprovalMode } from "./planner/plan-approval.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./duplicates/near-duplicate-canonical.js"; +export { resolveNearDuplicateCanonicalFlags } from "./duplicates/near-duplicate-canonical-flags.js"; export type { NearDuplicateCanonicalState } from "./duplicates/near-duplicate-canonical.js"; export { formatGitLabTrackedItemRef, isGitLabTrackingStale } from "./git/gitlab-tracking.js"; export * from "./planner/planner-intervention.js"; @@ -213,6 +214,7 @@ export * from "./mesh/mesh-task-replication.js"; export * from "./mesh/shared-mesh-state.js"; export { BUILTIN_AGENT_PROMPTS, + buildPlanningDuplicatePolicyInstruction, resolveAgentPrompt, buildTriageHeartbeatGuidance, buildConciseTriageHeartbeatGuidance, diff --git a/packages/dashboard/src/__tests__/routes-tasks-deterministic-dedup.test.ts b/packages/dashboard/src/__tests__/routes-tasks-deterministic-dedup.test.ts index 3ebd4ae0a7..4f2dea22b2 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-deterministic-dedup.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-deterministic-dedup.test.ts @@ -152,6 +152,64 @@ describe("task deterministic dedup", () => { expect((res.body as { details: { matches: Array<{ deterministic: boolean; id: string }> } }).details.matches[0]).toMatchObject({ deterministic: true, id: "FN-1" }); }); + it("allows a direct-user repeat of an exact completed task", async () => { + const { app, store, tasks } = buildApp([ + mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "done", source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }), + ]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + title: TITLE, + description: DESCRIPTION, + source: { sourceType: "dashboard_ui" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect((store.createTask as ReturnType)).toHaveBeenCalledTimes(1); + expect(tasks.find((task) => task.id !== "FN-1")?.column).toBe("todo"); + }); + + it("still blocks a direct-user duplicate of active work", async () => { + const { app } = buildApp([ + mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "todo", source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }), + ]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + title: TITLE, + description: DESCRIPTION, + source: { sourceType: "dashboard_ui" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(409); + }); + + it("allows a direct-user repeat of archived exact work", async () => { + const { app } = buildApp([ + mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "archived", source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }), + ]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + title: TITLE, + description: DESCRIPTION, + source: { sourceType: "dashboard_ui" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + }); + + it("allows a programmatic repeat of archived exact work", async () => { + const { app } = buildApp([ + mkTask({ id: "FN-1", title: TITLE, description: DESCRIPTION, column: "archived", source: { sourceType: "api", sourceMetadata: { contentFingerprint: FINGERPRINT } } }), + ]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + title: TITLE, + description: DESCRIPTION, + source: { sourceType: "api" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + }); + it("concurrent identical submissions keep one canonical row", async () => { const { app, tasks } = buildApp(); const body = JSON.stringify({ title: TITLE, description: DESCRIPTION }); diff --git a/packages/dashboard/src/__tests__/routes-tasks-duplicate-check.test.ts b/packages/dashboard/src/__tests__/routes-tasks-duplicate-check.test.ts index d037611973..920c88e13e 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-duplicate-check.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-duplicate-check.test.ts @@ -33,7 +33,9 @@ function buildApp(seed: Task[] = []) { const recordActivity = vi.fn().mockResolvedValue(undefined); const store: Partial = { - searchTasks: vi.fn().mockImplementation(async () => tasks), + searchTasks: vi.fn().mockImplementation(async (_query: string, options?: { includeArchived?: boolean }) => + options?.includeArchived ? tasks : tasks.filter((task) => task.column !== "archived"), + ), findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fingerprint: string, options?: { windowMs?: number; includeArchived?: boolean }) => { const windowMs = Math.max(1, Math.min(300_000, Math.trunc(options?.windowMs ?? 60_000))); const cutoff = Date.now() - windowMs; @@ -158,6 +160,23 @@ describe("task duplicate detection routes", () => { expect((res.body as { matches: unknown[] }).matches).toEqual([]); }); + it.each(["done", "archived"] as const)("returns no dashboard warning for %s history", async (column) => { + const { app } = buildApp([ + createTaskFixture({ id: "FN-11", title: "Duplicate warning", description: "Warn before task creation", column }), + ]); + + const res = await performRequest( + app, + "POST", + "/api/tasks/duplicate-check", + JSON.stringify({ description: "Warn before task creation" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect((res.body as { matches: unknown[] }).matches).toEqual([]); + }); + it("POST /tasks returns 409 when duplicate exists without acknowledgement", async () => { const { app } = buildApp([ createTaskFixture({ id: "FN-12", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }), @@ -284,9 +303,9 @@ describe("task duplicate detection routes", () => { expect(res.status).toBe(201); }); - it("done tasks do not trigger conflict", async () => { + it.each(["done", "archived"] as const)("programmatic repeats of %s tasks do not trigger conflict", async (column) => { const { app } = buildApp([ - createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column: "done" }), + createTaskFixture({ id: "FN-15", title: "Duplicate warning", description: "Warn before task creation", column }), ]); const res = await performRequest( diff --git a/packages/dashboard/src/__tests__/routes-tasks-explicit-duplicate-marker.test.ts b/packages/dashboard/src/__tests__/routes-tasks-explicit-duplicate-marker.test.ts index 77a598e65c..fc923d5d64 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-explicit-duplicate-marker.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-explicit-duplicate-marker.test.ts @@ -175,6 +175,42 @@ describe("routes /api/tasks explicit duplicate marker", () => { expect(tasks).toHaveLength(2); }); + it("allows a user-authored task to reference a completed duplicate target", async () => { + const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "done" }); + const { app, tasks } = buildApp([canonical]); + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + description: "DUPLICATE: FN-42", + source: { sourceType: "dashboard_ui" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(tasks).toHaveLength(2); + }); + + it("allows a programmatic task to reference a completed duplicate target", async () => { + const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "done" }); + const { app, tasks } = buildApp([canonical]); + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + description: "DUPLICATE: FN-42", + source: { sourceType: "api" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(tasks).toHaveLength(2); + }); + + it("allows a task to reference a live archived duplicate target", async () => { + const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "archived" }); + const { app, tasks } = buildApp([canonical]); + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + description: "DUPLICATE: FN-42", + source: { sourceType: "api" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(tasks).toHaveLength(2); + }); + it("does not block prose that merely mentions duplicate text", async () => { const canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" }); const { app, tasks } = buildApp([canonical]); diff --git a/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts b/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts index 637309704f..5f6679cec6 100644 --- a/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts +++ b/packages/dashboard/src/__tests__/routes-tasks-near-duplicate.test.ts @@ -30,8 +30,12 @@ function buildApp(seed: Task[]) { const tasks = [...seed]; const runtimeLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; const store: Partial = { - searchTasks: vi.fn().mockResolvedValue(tasks), - listTasks: vi.fn().mockResolvedValue(tasks), + searchTasks: vi.fn().mockImplementation(async (_query: string, options?: { includeArchived?: boolean }) => + options?.includeArchived ? tasks : tasks.filter((task) => task.column !== "archived"), + ), + listTasks: vi.fn().mockImplementation(async (options?: { includeArchived?: boolean }) => + options?.includeArchived ? tasks : tasks.filter((task) => task.column !== "archived"), + ), findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fingerprint: string) => tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fingerprint), ), @@ -161,6 +165,32 @@ describe("routes /api/tasks near duplicate", () => { expect(tasks).toHaveLength(1); }); + it.each(["done", "archived"] as const)("ignores a %s near-duplicate when the dashboard user creates the task", async (column) => { + vi.spyOn(core, "findDuplicateMatches").mockReturnValue([]); + const { app, tasks } = buildApp([{ ...reviewSeed, column }]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + ...reviewIncoming, + source: { sourceType: "dashboard_ui" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(tasks).toHaveLength(2); + }); + + it.each(["done", "archived"] as const)("ignores a %s near-duplicate from a programmatic source", async (column) => { + vi.spyOn(core, "findDuplicateMatches").mockReturnValue([]); + const { app, tasks } = buildApp([{ ...reviewSeed, column }]); + + const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ + ...reviewIncoming, + source: { sourceType: "api" }, + }), { "content-type": "application/json" }); + + expect(res.status).toBe(201); + expect(tasks).toHaveLength(2); + }); + it("acknowledgedDuplicates bypasses", async () => { const { app, tasks } = buildApp([routeSeed]); const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ ...routeIncoming, acknowledgedDuplicates: ["FN-5144"] }), { "content-type": "application/json" }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index e219e1f3f3..ef0b6c78b0 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -52,6 +52,8 @@ import { reconcileDeterministicDuplicate, extractIntentSignature, findNearDuplicates, + isNearDuplicateCanonicalInactive, + resolveNearDuplicateCanonicalFlags, isEphemeralAgent, parseExplicitDuplicateMarker, resolveWorkflowIrForTask, @@ -756,6 +758,7 @@ function buildDuplicateQuery(title: string | undefined, description: string): st async function computeDuplicateMatches( scopedStore: TaskStore, input: { title?: string; description: string; limit?: number; threshold?: number }, + classifyBlocker: (canonical: Pick) => Promise, ): Promise { const query = buildDuplicateQuery(input.title, input.description); if (query.length === 0) { @@ -767,7 +770,12 @@ async function computeDuplicateMatches( includeArchived: false, limit: 20, }); - const candidates: DuplicateCandidate[] = results.map((task) => ({ + const eligibility = await Promise.all(results.map(async (task) => ({ + task, + blocker: await classifyBlocker(task), + }))); + const eligibleResults = eligibility.filter(({ blocker }) => blocker).map(({ task }) => task); + const candidates: DuplicateCandidate[] = eligibleResults.map((task) => ({ id: task.id, title: task.title ?? "", description: task.description ?? "", @@ -787,6 +795,14 @@ async function computeDuplicateMatches( ); } +async function isDuplicateBlocker( + store: TaskStore, + canonical: Pick, +): Promise { + const flags = await resolveNearDuplicateCanonicalFlags(store, canonical); + return !isNearDuplicateCanonicalInactive(canonical, flags); +} + function buildReviewerAgentItemId(input: { index: number; reviewType: "plan" | "code"; step?: number; verdict?: string; createdAt?: string }): string { const stepPart = input.step ? `step-${input.step}` : "step-na"; const verdictPart = (input.verdict ?? "unknown").toLowerCase(); @@ -1408,7 +1424,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork description: description.trim(), limit, threshold, - }); + }, (canonical) => isDuplicateBlocker(scopedStore, canonical)); res.json({ matches }); return; @@ -1576,6 +1592,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork source && typeof source === "object" && "sourceType" in source && typeof (source as { sourceType?: unknown }).sourceType === "string" ? source : { sourceType: "api" as const }; + const duplicateBlockerVerdicts = new Map>(); + const classifyDuplicateBlocker = (canonical: Pick): Promise => { + const key = `${canonical.id}:${canonical.column}:${canonical.deletedAt ?? ""}`; + const existing = duplicateBlockerVerdicts.get(key); + if (existing) return existing; + const verdict = isDuplicateBlocker(scopedStore, canonical); + duplicateBlockerVerdicts.set(key, verdict); + return verdict; + }; const requestedBranchMode = getBranchSelectionMode(branchSelection); const { branch: normalizedBranch, baseBranch: normalizedBaseBranch, sharedFeatureBranch } = @@ -1668,7 +1693,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork let matchesAfterAckFilter: DuplicateMatch[] = []; try { - if (deterministicGuard.action === "duplicate" && deterministicGuard.existing) { + if ( + deterministicGuard.action === "duplicate" + && deterministicGuard.existing + && await classifyDuplicateBlocker(deterministicGuard.existing) + ) { throw conflict("duplicate_candidates", { matches: [{ id: deterministicGuard.existing.id, @@ -1686,7 +1715,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork : await computeDuplicateMatches(scopedStore, { title: normalizedTitle, description: normalizedDescription, - }); + }, classifyDuplicateBlocker); matchesAfterAckFilter = duplicateMatches.filter((match) => !acknowledgedDuplicateIds.includes(match.id)); if (matchesAfterAckFilter.length > 0) { throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter }); @@ -1715,11 +1744,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const fullRows = await scopedStore.listTasks({ slim: false, includeArchived: false }); const byId = new Map(fullRows.map((row) => [row.id, row])); const candidateMap = new Map(); - for (const row of candidateRows) { + const classifiedRows = await Promise.all(candidateRows.map(async (row) => { + const full = byId.get(row.id); + return { row, full, blocker: full ? await classifyDuplicateBlocker(full) : true }; + })); + for (const { row, full, blocker } of classifiedRows) { if (acknowledgedDuplicateIds.includes(row.id)) { continue; } - const full = byId.get(row.id); + if (!blocker) { + continue; + } candidateMap.set(row.id, { id: row.id, title: row.title ?? "", @@ -1772,7 +1807,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork (explicitDuplicateMarker ? acknowledgedDuplicateIds.includes(explicitDuplicateMarker.canonicalId) : false); if (explicitDuplicateMarker && !explicitMarkerBypassed) { const canonical = await scopedStore.getTask(explicitDuplicateMarker.canonicalId).catch(() => null); - if (canonical && !canonical.deletedAt) { + if ( + canonical + && !canonical.deletedAt + && await classifyDuplicateBlocker(canonical) + ) { try { // The intake guard runs before createTask, so there is no new task row yet. // Record against the canonical target to leave a traceable audit breadcrumb. @@ -1898,6 +1937,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint, windowMs: 60_000, logger: runtimeLogger, + onDuplicate: async (canonical) => + await classifyDuplicateBlocker(canonical) + ? "archive-created" + : "keep-created", }); if (deterministicReconcile.outcome === "archived") { res.status(200).json(deterministicReconcile.canonical); diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index 281e50d9b0..f18c9b5f0e 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -783,6 +783,40 @@ describe("createDelegateTaskTool", () => { }); } + for (const [label, completeColumn] of [["DEFAULT", "done"], ["RENAMED", RENAMED_VOCAB.complete]] as const) { + it(`does not select a completed same-agent task as a defined-feature bootstrap canonical (${label} complete lane: ${completeColumn})`, async () => { + const completed = { + id: "FN-completed", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature", + sourceAgentId: "agent-001", dependencies: [], column: completeColumn, steps: [], currentStep: 0, + log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + } as Task; + const ir = lifecycleIr(RENAMED_VOCAB, "agent-tools-complete"); + const store = createMockTaskStore({ + listTasks: vi.fn().mockResolvedValue([completed]), + ...(label === "RENAMED" + ? { + getTaskWorkflowSelectionAsync: (async () => ({ workflowId: "agent-tools-complete", stepIds: [] })) as never, + getTaskWorkflowSelection: (() => ({ workflowId: "agent-tools-complete", stepIds: [] })) as never, + getWorkflowDefinition: (async (id: string) => (id === "agent-tools-complete" ? { ir } : undefined)) as never, + } + : {}), + }); + const validate = vi.fn().mockResolvedValue(undefined); + + const result = await createAgentTask(store, { + title: "Bootstrap feature", + description: "Bootstrap the hand-authored feature", + source: { sourceType: "api", sourceAgentId: "agent-001" }, + preflightSameAgentDuplicate: true, + validateDuplicateCanonical: validate, + } as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise }); + + expect(result.wasDuplicate).toBe(false); + expect(validate).not.toHaveBeenCalled(); + expect(store.createTask).toHaveBeenCalledOnce(); + }); + } + it("serializes three concurrent paraphrased creates from one parent", async () => { const tasks: Task[] = []; vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks); diff --git a/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts b/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts index 02ade13fc3..b9131d6eec 100644 --- a/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts +++ b/packages/engine/src/__tests__/agent-tools-renamed-terminal-columns.test.ts @@ -106,17 +106,12 @@ describe("agent task-discovery tools resolve the terminal lane by ROLE, not by i }); } - it("fn_task_search still returns the finished card when includeDone is left at its default", async () => { - /* - The other direction, and the reason the helper is only invoked when `includeDone` is false: the tool - documents itself as searching "including done and archived tasks by default", which is what makes it - usable for duplicate detection. A conversion that filtered unconditionally would pass every case above. - */ + it("fn_task_search omits the finished card when includeDone is left at its default", async () => { const { store } = fixture(RENAMED_VOCAB); const result = await createTaskSearchTool(store).execute("call-3", { query: "a" } as never); - expect(result.content[0].text).toContain("FN-9102"); - expect(result.details).toMatchObject({ count: 3 }); + expect(result.content[0].text).not.toContain("FN-9102"); + expect(result.details).toMatchObject({ count: 2 }); }); it("falls back to the legacy terminal pair when the workflow cannot be resolved", async () => { diff --git a/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts b/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts index 5f3db3e444..26a55f0a7f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/explicit-duplicate-marker-sweep.test.ts @@ -14,7 +14,7 @@ function duplicateStub(canonicalId: string): string { async function createPromptTask( fx: ReliabilityFixture, - input: { id: string; column: "triage" | "todo" | "in-review"; title?: string; prompt: string }, + input: { id: string; column: "triage" | "todo" | "in-review"; title?: string; prompt: string; sourceType?: "api" | "dashboard_ui" }, ) { /* FNXC:ExplicitDuplicateMarkerSweep 2026-07-16-11:25: @@ -25,6 +25,7 @@ async function createPromptTask( const task = await fx.store.createTask({ title: input.title ?? input.id, description: `${input.id} description`, + source: input.sourceType ? { sourceType: input.sourceType } : undefined, }); if (input.column !== "triage") { await fx.store.moveTask(task.id, input.column); @@ -121,6 +122,32 @@ const canRun = hasGit && hasPg; )).toBe(true); }); + it.each([ + ["dashboard user", "dashboard_ui", "needs-replan"], + ["programmatic source", "api", "needs-replan"], + ] as const)("handles a repeated inactive marker from a %s according to provenance", async (_label, sourceType, expectedStatus) => { + const fx = await makeReliabilityFixture({ settings: { taskPrefix: "FN", triageDuplicateResolution: "prompt" } }); + fixtures.push(fx); + + const canonical = await fx.store.createTask({ title: "Completed canonical", description: "canonical", column: "done" }); + const duplicate = await createPromptTask(fx, { id: "FN-5302", column: "triage", prompt: duplicateStub(canonical.id), sourceType }); + await fx.store.updateTask(duplicate.id, { + sourceMetadataPatch: { + nearDuplicateOf: canonical.id, + duplicateSource: "triage-marker", + nearDuplicateDismissed: true, + duplicateMarkerClearCount: 1, + }, + }); + + await (fx.manager as any).resolveExplicitDuplicateMarkerTasks(); + + const updated = await fx.store.getTask(duplicate.id); + expect(updated.status).toBe(expectedStatus); + expect(updated.error ?? null).toBeNull(); + expect(updated.sourceMetadata).toEqual(expect.objectContaining({ duplicateMarkerClearCount: 2 })); + }); + it.each([ ["user pause", { userPaused: true, paused: true, pausedReason: "manual" }], ["implicit user pause", { paused: true, pausedReason: null }], diff --git a/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts b/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts index 3bbf2a0572..b27cdf4736 100644 --- a/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts +++ b/packages/engine/src/__tests__/triage-duplicate-search-regression.test.ts @@ -32,7 +32,7 @@ const FAST_PLANNING_PROMPT = builtinSeamPrompt("planning-fast"); /** * FN-4726 / FN-4734 / FN-4741: triage created repeated duplicate tasks after equivalent * work had already landed. FN-4774 fixed this by (1) exposing fn_task_search in triage, - * (2) guiding the canonical triage policy prompt to search done/archived before creating, and + * (2) guiding the canonical triage policy prompt to search active work before creating, and * (3) preserving that guidance in FAST_PLANNING_PROMPT. FN-4815 pins this contract. */ describe("FN-4815 triage duplicate-search regression", () => { @@ -53,18 +53,20 @@ describe("FN-4815 triage duplicate-search regression", () => { it("standard prompt guidance keeps duplicate-search instructions", () => { expect(TRIAGE_POLICY_PROMPT).toContain("Duplicate check"); expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search"); - expect(TRIAGE_POLICY_PROMPT).toContain("including done and archived tasks"); + expect(TRIAGE_POLICY_PROMPT).toContain("includeDone: false"); + expect(TRIAGE_POLICY_PROMPT).toContain("includeArchived: false"); expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(TRIAGE_POLICY_PROMPT)).toBe(true); }); it("fast prompt guidance keeps duplicate-search instructions", () => { expect(FAST_PLANNING_PROMPT).toContain("Duplicate check"); expect(FAST_PLANNING_PROMPT).toContain("fn_task_search"); - expect(FAST_PLANNING_PROMPT).toContain("For any likely match in `done` or `archived`"); + expect(FAST_PLANNING_PROMPT).toContain("includeDone: false"); + expect(FAST_PLANNING_PROMPT).toContain("includeArchived: false"); expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(FAST_PLANNING_PROMPT)).toBe(true); }); - it("end-to-end duplicate discovery via fixture shows done match before create", async () => { + it("end-to-end duplicate discovery excludes done matches by default", async () => { const scenario = createTriageDuplicateScenario(); const store = scenario.buildMockStore(); const processor = new TriageProcessor(store, "/tmp/root"); @@ -82,11 +84,10 @@ describe("FN-4815 triage duplicate-search regression", () => { expect(store.searchTasks).toHaveBeenCalledWith(scenario.searchQuery, { slim: true, - includeArchived: true, + includeArchived: false, limit: 20, }); - expect(output).toContain(`${scenario.doneTask.id} (done):`); - expect(output).toContain("(done)"); + expect(output).not.toContain(`${scenario.doneTask.id} (done):`); }); it("FN-4726/FN-4734: recently-merged done task touching register-session-diff-routes.ts surfaces via fn_task_search", async () => { diff --git a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts index f92b5ac30f..e0f294508c 100644 --- a/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts +++ b/packages/engine/src/__tests__/triage-explicit-duplicate-marker.test.ts @@ -54,11 +54,12 @@ describe("triage explicit duplicate marker short-circuit", () => { it("deletes the duplicate task and records explicit-marker activity", async () => { const canonical = createTask({ id: "FN-001", title: "Canonical task", column: "todo" }); + const task = createTask(); const store = createMockStore({ - getTask: vi.fn().mockImplementation(async (id: string) => (id === canonical.id ? canonical : null)), + getTask: vi.fn().mockImplementation(async (id: string) => (id === canonical.id ? canonical : task)), }); - await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "delete" })).resolves.toBe(true); + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n", { ...settings, triageDuplicateResolution: "delete" })).resolves.toBe(true); expect((store as any).deleteTaskIf).toHaveBeenCalledWith("FN-002", expect.any(Function), expect.objectContaining({ removeLineageReferences: true, @@ -77,13 +78,31 @@ describe("triage explicit duplicate marker short-circuit", () => { it("flags and system-pauses duplicates by default instead of deleting", async () => { const canonical = createTask({ id: "FN-001", column: "todo" }); - const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); - await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true); + const task = createTask(); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + }); + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); expect(store.deleteTask).not.toHaveBeenCalled(); expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ paused: true, pausedReason: "duplicate-decision-required" })); expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", duplicateSource: "triage-marker" }) })); }); + it("still pauses a user-authored task when the duplicate target is active", async () => { + const canonical = createTask({ id: "FN-001", column: "in-progress" }); + const task = createTask({ sourceType: "dashboard_ui" }); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + }); + + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + paused: true, + pausedReason: "duplicate-decision-required", + })); + }); + it("keeps a marker duplicate by clearing its system pause for replanning", async () => { const canonical = createTask({ id: "FN-001", column: "todo" }); const task = createTask(); @@ -135,7 +154,9 @@ describe("triage explicit duplicate marker short-circuit", () => { const task = createTask({ sourceMetadata: { nearDuplicateOf: "FN-003", duplicateSource: "triage-marker", nearDuplicateDismissed: true }, }); - const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), + }); await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); @@ -155,7 +176,7 @@ describe("triage explicit duplicate marker short-circuit", () => { sourceMetadata: { nearDuplicateOf: "FN-001", duplicateSource: "triage-marker", nearDuplicateDismissed: true }, }); const store = createMockStore({ - getTask: vi.fn().mockResolvedValue(canonical), + getTask: vi.fn().mockImplementation(async (id: string) => id === canonical.id ? canonical : task), readTaskForMove: vi.fn().mockResolvedValue(task), }); @@ -198,7 +219,7 @@ describe("triage explicit duplicate marker short-circuit", () => { expect(store.deleteTask).not.toHaveBeenCalled(); }); - it("parks failed when an inactive DUPLICATE is re-emitted after dismissal (FN-8704 loop stop)", async () => { + it("replans programmatic work when an inactive DUPLICATE is re-emitted after dismissal", async () => { const task = createTask({ sourceMetadata: { nearDuplicateOf: "FN-001", @@ -217,12 +238,38 @@ describe("triage explicit duplicate marker short-circuit", () => { await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ - status: "failed", - error: expect.stringContaining("DUPLICATE_REPLAN_EXHAUSTED"), - sourceMetadataPatch: expect.objectContaining({ + status: "needs-replan", + error: null, + sourceMetadataPatch: expect.objectContaining({ duplicateMarkerClearCount: 2 }), + })); + }); + + it("replans a user-authored task when the planner re-emits a completed duplicate", async () => { + const task = createTask({ + sourceType: "dashboard_ui", + sourceMetadata: { nearDuplicateOf: "FN-001", + duplicateSource: "triage-marker", nearDuplicateDismissed: true, - }), + duplicateMarkerClearCount: 1, + }, + }); + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => + id === "FN-001" ? createTask({ id: "FN-001", column: "done" }) : task, + ), + readTaskForMove: vi.fn().mockResolvedValue(task), + }); + + await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true); + + expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ + status: "needs-replan", + error: null, + sourceMetadataPatch: expect.objectContaining({ duplicateMarkerClearCount: 2 }), + })); + expect(store.updateTask).not.toHaveBeenCalledWith("FN-002", expect.objectContaining({ + status: "failed", })); }); diff --git a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts index 7dd2320c1c..bded2300bf 100644 --- a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts +++ b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts @@ -117,6 +117,25 @@ async function captureBasePrompt(task: Task, store: TaskStore): Promise return captured; } +async function capturePromptLayers(task: Task, store: TaskStore): Promise { + let captured = ""; + mockCreateFnAgent.mockImplementationOnce(async (opts: any) => { + captured = JSON.stringify(opts.systemPromptLayers ?? opts.systemPrompt); + return { + session: { + state: {}, + sessionManager: { getLeafId: vi.fn().mockReturnValue(null) }, + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + navigateTree: vi.fn(), + }, + }; + }); + + await new TriageProcessor(store, "/tmp/root").specifyTask(task); + return captured; +} + const canonicalPlanningPrompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR)!; const renderedCanonicalPlanningPrompt = renderTriagePolicyPlaceholders(canonicalPlanningPrompt, {}); const renderedDefaultTriagePrompt = renderTriagePolicyPlaceholders(resolveAgentPrompt("triage"), {}); @@ -200,6 +219,20 @@ describe("triage planning prompt single source", () => { await expect(captureBasePrompt(task, store)).resolves.toBe(renderedFastPlanningPrompt); }); + it.each(["standard", "fast"] as const)("injects direct-user duplicate policy into the %s planning session", async (executionMode) => { + const task = createTask({ id: `FN-6232-USER-${executionMode}`, executionMode, sourceType: "dashboard_ui" }); + const store = createStore(task); + + await expect(capturePromptLayers(task, store)).resolves.toContain("Only active tasks can be duplicate blockers"); + }); + + it("injects active-only duplicate policy into programmatic planning sessions", async () => { + const task = createTask({ id: "FN-6232-API", executionMode: "standard", sourceType: "api" }); + const store = createStore(task); + + await expect(capturePromptLayers(task, store)).resolves.toContain("Only active tasks can be duplicate blockers"); + }); + it("uses a selected custom workflow planning prompt", async () => { const task = createTask({ id: "FN-6232-CUSTOM", executionMode: "standard" }); const customPrompt = "custom workflow planning prompt"; diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index fcdfb156ee..6333cbdd3d 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -7664,7 +7664,7 @@ describe("TriageProcessor delegation tools", () => { expect(toolNames).not.toContain("fn_delegate_task"); }); - it("fn_task_search includes done tasks by default and searches with includeArchived true", async () => { + it("fn_task_search excludes done tasks by default and searches with includeArchived false", async () => { const store = createMockStore(); (store.searchTasks as any).mockResolvedValue([ { @@ -7695,11 +7695,11 @@ describe("TriageProcessor delegation tools", () => { expect(store.searchTasks).toHaveBeenCalledWith("rebase diff", { slim: true, - includeArchived: true, + includeArchived: false, limit: 20, }); - expect(text).toContain('Search results for "rebase diff" (2):'); - expect(text).toContain("FN-100 (done): Fix rebase merge truncation"); + expect(text).toContain('Search results for "rebase diff" (1):'); + expect(text).not.toContain("FN-100 (done): Fix rebase merge truncation"); }); it("fn_task_search filters done tasks when includeDone is false", async () => { @@ -7812,11 +7812,11 @@ describe("FN-4774 regression: triage duplicate detection over done/archived task }); // Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md - it("canonical triage policy prompt guides agents to search done/archived before creating", () => { + it("canonical triage policy prompt guides agents to exclude done/archived duplicates", () => { // Standard prompt mentions fn_task_search in duplicate-check guidance expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search"); - // The tool bullet list explicitly states it covers done and archived - expect(TRIAGE_POLICY_PROMPT).toContain("including done and archived tasks"); + expect(TRIAGE_POLICY_PROMPT).toContain("includeDone: false"); + expect(TRIAGE_POLICY_PROMPT).toContain("includeArchived: false"); // Duplicate-check section co-locates fn_task_search with done/archived references expect(TRIAGE_POLICY_PROMPT).toContain("done"); expect(TRIAGE_POLICY_PROMPT).toContain("archived"); @@ -7829,12 +7829,11 @@ describe("FN-4774 regression: triage duplicate detection over done/archived task }); // Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md - it("FAST_PLANNING_PROMPT guides agents to search done/archived before creating", () => { + it("FAST_PLANNING_PROMPT guides agents to exclude done/archived duplicates", () => { // Fast prompt mentions fn_task_search expect(FAST_PLANNING_PROMPT).toContain("fn_task_search"); - // Duplicate-check section references done and archived - expect(FAST_PLANNING_PROMPT).toContain("done"); - expect(FAST_PLANNING_PROMPT).toContain("archived"); + expect(FAST_PLANNING_PROMPT).toContain("includeDone: false"); + expect(FAST_PLANNING_PROMPT).toContain("includeArchived: false"); // Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived expect( /Duplicate check[\s\S]{0,600}fn_task_search[\s\S]{0,400}(done|archived)/i.test( @@ -7844,7 +7843,7 @@ describe("FN-4774 regression: triage duplicate detection over done/archived task }); // Regression: FN-4774 (FN-4827 recovery; supersedes FN-4815) — see docs/triage-duplicate-detection-postmortem.md - it("fn_task_search returns done-column results by default", async () => { + it("fn_task_search excludes done-column results by default", async () => { const store = createMockStore(); (store.searchTasks as any).mockResolvedValue([ { @@ -7868,15 +7867,13 @@ describe("FN-4774 regression: triage duplicate detection over done/archived task }); const text = result.content[0].text; - // searchTasks is called with includeArchived: true (includeDone is a post-filter, not passed to searchTasks) + // searchTasks excludes archived rows and completed rows are filtered after lookup. expect(store.searchTasks).toHaveBeenCalledWith("rebase truncation", { slim: true, - includeArchived: true, + includeArchived: false, limit: 20, }); - // Done results surface in output with the (done): column label - expect(text).toContain("FN-DONE"); - expect(text).toContain("(done):"); + expect(text).not.toContain("FN-DONE"); }); }); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 3f0864a642..0a81872ed2 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -1250,7 +1250,7 @@ async function findDefinedFeatureBootstrapDuplicate( this path — on a renamed archive lane an archived sibling became a bootstrap canonical, and `claimDefinedFeatureTask` then rejects the non-live row, so the claim fails outright. */ - const isArchivedCandidate = await resolveArchivedColumnsForTasks(store, candidates); + const isTerminalCandidate = await resolveTerminalColumnsForTasks(store, candidates); const matches = findSameAgentDuplicates({ title: input.title, description: input.description, @@ -1263,7 +1263,7 @@ async function findDefinedFeatureBootstrapDuplicate( task boundary. An archived sibling cannot be a bootstrap canonical because claimDefinedFeatureTask rejects non-live task rows. */ - if (Number.isNaN(createdAt) || task.deletedAt || isArchivedCandidate(task)) return []; + if (Number.isNaN(createdAt) || task.deletedAt || isTerminalCandidate(task)) return []; return [{ id: task.id, title: task.title ?? "", @@ -1352,31 +1352,6 @@ export async function resolveTerminalColumnsForTasks( return (task: Task) => terminalByTaskId.get(task.id)?.has(task.column) === true; } -/** - * MEMBERSHIP over the `archived` role for a fixed task set, unioned with the legacy id. - * - * Split from `resolveTerminalColumnsForTasks` rather than parameterised: the two callers ask genuinely - * different questions — "is this finished?" (complete OR archived) versus "is this archived?" — and - * collapsing them would make an archived-only guard also reject completed rows. - */ -async function resolveArchivedColumnsForTasks( - store: TaskStore, - tasks: readonly Task[], -): Promise<(task: Task) => boolean> { - const cache = new Map>>(); - const archivedByTaskId = new Map>(); - for (const task of tasks) { - if (archivedByTaskId.has(task.id)) continue; - const columns = new Set(["archived"]); - try { - const ir = await fusionCore.resolveWorkflowIrForTask(store, task.id, cache); - if (ir) for (const id of fusionCore.columnsWithFlag(ir, "archived")) columns.add(id); - } catch { /* degraded: legacy id only */ } - archivedByTaskId.set(task.id, columns); - } - return (task: Task) => archivedByTaskId.get(task.id)?.has(task.column) === true; -} - export async function createAgentTask( store: TaskStore, input: TaskCreateInput, @@ -1467,11 +1442,12 @@ export async function createAgentTask( try { const acknowledged = new Set(options?.acknowledgedDuplicates ?? []); const candidates = await store.findRecentTasksBySourceParentTaskId(sourceParentTaskId); + const isTerminalCandidate = await resolveTerminalColumnsForTasks(store, candidates); const matches = findSameAgentDuplicates({ title: input.title, description: input.description, sourceParentTaskId, - }, candidates.map((candidate) => ({ + }, candidates.filter((candidate) => !isTerminalCandidate(candidate)).map((candidate) => ({ id: candidate.id, title: candidate.title ?? "", description: candidate.description, @@ -1816,8 +1792,8 @@ export function createTaskSearchTool(store: TaskStore): ToolDefinition { name: "fn_task_search", label: "Search Tasks", description: - "Keyword search across tasks, including done and archived tasks by default. " + - "Use for duplicate detection and work discovery before filing new tasks.", + "Keyword search across active tasks by default. " + + "Done and archived history is opt-in and must not be used for duplicate detection.", parameters: taskSearchParams, execute: async (_id: string, params: Static) => { const query = params.query.trim(); @@ -1830,10 +1806,10 @@ export function createTaskSearchTool(store: TaskStore): ToolDefinition { const limit = Math.min(50, Math.max(1, Math.floor(params.limit ?? 20))); const results = await store.searchTasks(query, { slim: true, - includeArchived: params.includeArchived ?? true, + includeArchived: params.includeArchived ?? false, limit, }); - const includeDone = params.includeDone ?? true; + const includeDone = params.includeDone ?? false; const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results); const filtered = includeDone ? results : results.filter((task) => !isTerminalResult!(task)); const lines = filtered.map(formatTaskSummaryLine); diff --git a/packages/engine/src/duplicate-marker-clear.ts b/packages/engine/src/duplicate-marker-clear.ts index 9342625241..0bc1f52e65 100644 --- a/packages/engine/src/duplicate-marker-clear.ts +++ b/packages/engine/src/duplicate-marker-clear.ts @@ -8,9 +8,8 @@ * FS-fails, and storms (observed on FN-8704 / inactive FN-8676). * * FNXC:NearDuplicateDetection 2026-08-02-00:46: - * First clear of an inactive/dismissed-id marker gets one needs-replan chance. - * A second DUPLICATE write for the same already-dismissed canonical parks failed - * (DUPLICATE_REPLAN_EXHAUSTED) so triage stops re-admitting the card forever. + * Inactive/dismissed-id markers are cleared for replanning because completed and archived work is + * historical context, never an actionable duplicate, regardless of task provenance. */ /** Log action picked up by triage's needs-replan feedback scanner. */ diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 6733061803..3819670c69 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -31,6 +31,7 @@ import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, 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, + resolveNearDuplicateCanonicalFlags, LEGACY_COLUMN_IDS_BY_ROLE, TERMINAL_ROLES, resolveProjectColumnsForRoles, @@ -846,34 +847,6 @@ function hasTerminalInvalidDoneTransition(task: Pick): boolean { /** Sentinel for a workflow selection whose READ failed, distinct from "no selection". */ const UNREADABLE_WORKFLOW_SELECTION = "\u0000unreadable-workflow-selection"; -/* -FNXC:WorkflowResolvedColumns 2026-07-30-04:10: -The canonical's OWN resolved column flags, for `isNearDuplicateCanonicalInactive`. - -Omitted, that predicate falls back to the legacy `done`/`archived` ids, so on a renamed board a -canonical that has SHIPPED reads as still ACTIVE. Every "the canonical is inactive, so clear the -marker" branch below then fails to fire, and FN-8356's fix — inactive canonicals flow through marker -cleanup rather than parking the card — is inert. The card keeps its "Needs your decision" badge -pointing at work that finished days ago, and no decision can ever resolve it. - -Module-private rather than shared: `findColumn` is already duplicated this way in hold-release.ts, -merge-trait.ts, and workflow-capacity.ts, so this follows the established shape instead of adding a -cross-module helper for it. - -`undefined` on any failure is deliberate — it degrades to the legacy id rather than to absent traits -that match nothing. -*/ -async function resolveNearDuplicateCanonicalFlags( - store: TaskStore, - canonical: { id: string; column?: string | null } | null | undefined, -): Promise | undefined> { - if (!canonical?.column) return undefined; - const ir = await resolveWorkflowIrForTask(store, canonical.id).catch(() => undefined); - if (!ir || ir.version !== "v2") return undefined; - const column = ir.columns.find((candidate) => candidate.id === canonical.column); - return column ? resolveColumnFlags(column) : undefined; -} - export class SelfHealingManager extends SelfHealingGitEvidence { // ── Auto-unpause state ────────────────────────────────────────────── private unpauseTimer: ReturnType | null = null; @@ -14506,16 +14479,12 @@ const movedTask = await this.store.moveTask(task.id, completeLane); const priorClearCount = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number" ? task.sourceMetadata.duplicateMarkerClearCount : 0; - const alreadyDismissed = isTriageDuplicateKeepAcknowledged(task.sourceMetadata, marker.canonicalId); - const exhausted = alreadyDismissed || priorClearCount >= 1; - const patch = exhausted - ? buildMarkerExhaustedFailedTaskPatch(marker.canonicalId, priorClearCount) - : buildMarkerClearedReplanTaskPatch(marker.canonicalId, priorClearCount); + const patch = buildMarkerClearedReplanTaskPatch(marker.canonicalId, priorClearCount); await this.store.updateTask(task.id, patch); if (typeof this.store.logEntry === "function") { await Promise.resolve(this.store.logEntry( task.id, - exhausted ? buildDuplicateReplanExhaustedError(marker.canonicalId) : TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, + TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, buildInactiveDuplicateClearFeedback(marker.canonicalId), )).catch(() => {}); } diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 23026ee887..1e6da63e57 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -23,6 +23,7 @@ import { getTaskDuplicateLineage, parseExplicitDuplicateMarker, resolveAgentPrompt, + buildPlanningDuplicatePolicyInstruction, builtinSeamPrompt, renderTriagePolicyPlaceholders, resolveEffectiveSettingsDetailed, @@ -44,7 +45,8 @@ import { computePlanApprovalFingerprint, extractIntentSignature, findNearDuplicates, - isNearDuplicateCanonicalInactive, resolveColumnFlags, + isNearDuplicateCanonicalInactive, + resolveNearDuplicateCanonicalFlags, detectImageMimeFromBytes, applyFrontendUxCriteria, applyOriginalDescription, @@ -355,35 +357,6 @@ no longer has. */ const LEGACY_PLANNER_COLUMN_IDS: ReadonlySet = new Set(["triage", "todo"]); -/* -FNXC:WorkflowResolvedColumns 2026-07-30-04:10: -The canonical's OWN resolved column flags, for `isNearDuplicateCanonicalInactive`. - -Omitted, that predicate falls back to the legacy `done`/`archived` ids, so on a renamed board a -canonical that has SHIPPED reads as still ACTIVE. Every "the canonical is inactive, so clear the -marker" branch below then fails to fire, and FN-8356's fix — inactive canonicals flow through marker -cleanup rather than parking the card — is inert. The card keeps its "Needs your decision" badge -pointing at work that finished days ago, and no decision can ever resolve it. - -Module-private rather than shared: `findColumn` is already duplicated this way in hold-release.ts, -merge-trait.ts, and workflow-capacity.ts, so this follows the established shape instead of adding a -cross-module helper for it. - -`undefined` on any failure is deliberate — it degrades to the legacy id rather than to absent traits -that match nothing. -*/ -async function resolveNearDuplicateCanonicalFlags( - store: TaskStore, - canonical: { id: string; column?: string | null } | null | undefined, -): Promise | undefined> { - if (!canonical?.column) return undefined; - const ir = await resolveWorkflowIrForTask(store, canonical.id).catch(() => undefined); - if (!ir || ir.version !== "v2") return undefined; - const column = ir.columns.find((candidate) => candidate.id === canonical.column); - return column ? resolveColumnFlags(column) : undefined; -} - - /* FNXC:WorkflowResolvedColumns 2026-07-31-23:59 DELIBERATE-LITERAL: the migration arm, and only it. @@ -2709,11 +2682,13 @@ export class TriageProcessor { // fast prompts. Fast mode currently has no policy placeholders, making // this a no-op there while still guaranteeing no dangling token leaks. const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, triagePolicySettings); + const duplicatePolicyInstruction = buildPlanningDuplicatePolicyInstruction(); const triageLayers = buildPromptLayers({ basePrompt: renderedBasePrompt, goalContext: triageGoalResolution.goalContext, agentInstructions: [ triageIdentitySection, + duplicatePolicyInstruction, triageInstructions, isResearchToolSurfaceEnabled(settings) ? getResearchGuidanceForSurface("triage") @@ -3673,8 +3648,8 @@ export class TriageProcessor { name: "fn_task_search", label: "Search Tasks", description: - "Keyword search across tasks, including done and archived tasks by default. " + - "Use for duplicate detection before filing a new task.", + "Keyword search across active tasks by default. " + + "Done and archived history is opt-in and must not be used for duplicate detection.", parameters: taskSearchParams, execute: async ( _callId: string, @@ -3689,10 +3664,10 @@ export class TriageProcessor { } const results = await store.searchTasks(query, { slim: true, - includeArchived: params.includeArchived ?? true, + includeArchived: params.includeArchived ?? false, limit: params.limit ?? 20, }); - const includeDone = params.includeDone ?? true; + const includeDone = params.includeDone ?? false; const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results); const filtered = includeDone ? results @@ -4311,16 +4286,14 @@ export class TriageProcessor { if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined, canonicalFlags)) { if (canClearInactiveMarker) { /* - First inactive clear: replan once with dismissal stamped. - Re-emit of the same dismissed inactive id (or clearCount>=1): park failed so - triage eligibility (status:failed) stops the FN-8704 forever-loop. + Completed and archived work is historical context, never an accepted duplicate verdict. + Clear the marker and require a fresh plan regardless of how the new task was created. */ - const alreadyDismissed = fusionCore.isTriageDuplicateKeepAcknowledged(liveMeta, canonicalId); await this.clearDuplicateMarkerForReplan( task, canonicalId, buildInactiveDuplicateClearFeedback(canonicalId), - { exhausted: alreadyDismissed || priorClearCount >= 1, priorClearCount }, + { exhausted: false, priorClearCount }, ); } return;