fix(FN-8785): ignore completed duplicate history

Fusion-Task-Id: FN-8785
This commit is contained in:
gsxdsm
2026-08-04 10:52:12 -07:00
parent 07dccbe2bd
commit 1d1a7fedab
24 changed files with 535 additions and 172 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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();

View File

@@ -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: <name>\` (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).

View File

@@ -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<Task | undefined> {
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),

View File

@@ -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<ReturnType<typeof resolveColumnFlags> | 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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<typeof vi.fn>)).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 });

View File

@@ -33,7 +33,9 @@ function buildApp(seed: Task[] = []) {
const recordActivity = vi.fn().mockResolvedValue(undefined);
const store: Partial<TaskStore> = {
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(

View File

@@ -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]);

View File

@@ -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<TaskStore> = {
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" });

View File

@@ -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<Task, "id" | "column" | "deletedAt">) => Promise<boolean>,
): Promise<DuplicateMatch[]> {
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<Task, "id" | "column" | "deletedAt">,
): Promise<boolean> {
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<string, Promise<boolean>>();
const classifyDuplicateBlocker = (canonical: Pick<Task, "id" | "column" | "deletedAt">): Promise<boolean> => {
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<string, NearDuplicateCandidate>();
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);

View File

@@ -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<void> });
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);

View File

@@ -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 () => {

View File

@@ -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 }],

View File

@@ -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 () => {

View File

@@ -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",
}));
});

View File

@@ -117,6 +117,25 @@ async function captureBasePrompt(task: Task, store: TaskStore): Promise<string>
return captured;
}
async function capturePromptLayers(task: Task, store: TaskStore): Promise<string> {
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";

View File

@@ -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");
});
});

View File

@@ -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<string, Awaited<ReturnType<typeof fusionCore.resolveWorkflowIrForTask>>>();
const archivedByTaskId = new Map<string, ReadonlySet<string>>();
for (const task of tasks) {
if (archivedByTaskId.has(task.id)) continue;
const columns = new Set<string>(["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<typeof taskSearchParams>) => {
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);

View File

@@ -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. */

View File

@@ -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<Task, "error">): 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<ReturnType<typeof resolveColumnFlags> | 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<typeof setTimeout> | 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(() => {});
}

View File

@@ -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<string> = 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<ReturnType<typeof resolveColumnFlags> | 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;