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, getTemplatesForRole,
FUSION_RUNTIME_SELF_AWARENESS, FUSION_RUNTIME_SELF_AWARENESS,
TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION, TRIAGE_HEARTBEAT_PATROL_DISABLED_INSTRUCTION,
buildPlanningDuplicatePolicyInstruction,
} from "../agents/agent-prompts.js"; } from "../agents/agent-prompts.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../workflows/builtin-coding-workflow-ir.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../workflows/builtin-coding-workflow-ir.js";
import { BUILTIN_SEAM_PROMPTS, builtinSeamPrompt } from "../workflows/builtin-workflow-prompts.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", () => { 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", () => { it("returns the correct built-in prompt for executor when no config provided", () => {
const result = resolveAgentPrompt("executor"); const result = resolveAgentPrompt("executor");
expect(result).toBeTruthy(); expect(result).toBeTruthy();

View File

@@ -109,6 +109,54 @@ describe("runDeterministicDuplicateGuard", () => {
result.releaseLock(); 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 () => { it("scopes exact duplicates to the creating parent task", async () => {
const foreignSibling = mkTask({ const foreignSibling = mkTask({
id: "FN-1", id: "FN-1",
@@ -246,6 +294,20 @@ describe("runDeterministicDuplicateGuard", () => {
}); });
describe("reconcileDeterministicDuplicate", () => { 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 () => { it("does not archive an identical task created by a different parent", async () => {
const canonicalTs = new Date(Date.now() - 2_000).toISOString(); const canonicalTs = new Date(Date.now() - 2_000).toISOString();
const createdTs = new Date().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. - Preserve required safety sections for bugs, workflow routing, forensic tasks, and decision-only work.
## Duplicate check ## 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 ## 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. 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. - 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 ## 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). 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).
For any likely match in \`done\` or \`archived\`, call \`fn_task_show\` to inspect details before deciding. Do not search completed or archived work for duplicate candidates.
If a task already covers the same work (even if worded differently), do not write a spec. 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: Instead you MUST still write PROMPT.md, with its ENTIRE contents being this one line and nothing else:
\`DUPLICATE: {existing-task-id}\` \`DUPLICATE: {existing-task-id}\`
Writing that file IS how you report the duplicate. Reporting it only in your reply is not recorded: 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 ## Triage tools
You have these extra tools during triage: You have these extra tools during triage:
- \`fn_task_list\` — list existing active tasks - \`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_show\` — inspect a task and its PROMPT.md
- \`fn_task_create\` — create a child/follow-up task while triaging - \`fn_task_create\` — create a child/follow-up task while triaging
- \`fn_task_document_write\` — save a planning document (e.g., key="plan") - \`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. - **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 * Base merger prompt text (without commit format instructions, which are
* appended dynamically by the merger's buildMergeSystemPrompt function). * appended dynamically by the merger's buildMergeSystemPrompt function).

View File

@@ -1,6 +1,8 @@
import type { Task } from "../types.js"; import type { Task } from "../types.js";
import type { TaskStore } from "../store.js"; import type { TaskStore } from "../store.js";
import { computeContentFingerprint } from "./duplicate-detection.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"; 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; 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( export async function runDeterministicDuplicateGuard(
store: TaskStore, store: TaskStore,
input: { title?: string | null; description: string }, input: { title?: string | null; description: string },
@@ -88,7 +103,7 @@ export async function runDeterministicDuplicateGuard(
windowMs, windowMs,
includeArchived: false, includeArchived: false,
}); });
const deterministicConflict = deterministicMatches.find((match) => const deterministicConflict = await findActiveDuplicate(store, deterministicMatches, (match) =>
matchesParentScope(match, opts?.sourceParentTaskId) && !acknowledged.has(match.id), matchesParentScope(match, opts?.sourceParentTaskId) && !acknowledged.has(match.id),
); );
if (deterministicConflict) { if (deterministicConflict) {
@@ -140,7 +155,7 @@ export async function runDeterministicDuplicateGuard(
windowMs, windowMs,
includeArchived: false, includeArchived: false,
}); });
const deterministicConflict = deterministicMatches.find((match) => const deterministicConflict = await findActiveDuplicate(store, deterministicMatches, (match) =>
matchesParentScope(match, opts.sourceParentTaskId) && !acknowledged.has(match.id), matchesParentScope(match, opts.sourceParentTaskId) && !acknowledged.has(match.id),
); );
if (deterministicConflict) { if (deterministicConflict) {
@@ -179,7 +194,7 @@ export async function reconcileDeterministicDuplicate(
includeArchived: false, includeArchived: false,
}); });
const olderSibling = siblings.find((sibling) => const olderSibling = await findActiveDuplicate(store, siblings, (sibling) =>
sibling.id !== args.createdTask.id sibling.id !== args.createdTask.id
&& sibling.createdAt < args.createdTask.createdAt && sibling.createdAt < args.createdTask.createdAt
&& matchesParentScope(sibling, args.sourceParentTaskId), && 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 { computePlanApprovalFingerprint, isPlanReviewSatisfied, resolvePlanApprovalRequired } from "./planner/plan-approval.js";
export type { PlanApprovalMode } from "./planner/plan-approval.js"; export type { PlanApprovalMode } from "./planner/plan-approval.js";
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./duplicates/near-duplicate-canonical.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 type { NearDuplicateCanonicalState } from "./duplicates/near-duplicate-canonical.js";
export * from "./tasks/frontend-ux-policy.js"; export * from "./tasks/frontend-ux-policy.js";
export * from "./tasks/original-description-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 * from "./mesh/shared-mesh-state.js";
export { export {
BUILTIN_AGENT_PROMPTS, BUILTIN_AGENT_PROMPTS,
buildPlanningDuplicatePolicyInstruction,
resolveAgentPrompt, resolveAgentPrompt,
buildTriageHeartbeatGuidance, buildTriageHeartbeatGuidance,
buildConciseTriageHeartbeatGuidance, 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 { computePlanApprovalFingerprint, isPlanReviewSatisfied, resolvePlanApprovalRequired } from "./planner/plan-approval.js";
export type { PlanApprovalMode } from "./planner/plan-approval.js"; export type { PlanApprovalMode } from "./planner/plan-approval.js";
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./duplicates/near-duplicate-canonical.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 type { NearDuplicateCanonicalState } from "./duplicates/near-duplicate-canonical.js";
export { formatGitLabTrackedItemRef, isGitLabTrackingStale } from "./git/gitlab-tracking.js"; export { formatGitLabTrackedItemRef, isGitLabTrackingStale } from "./git/gitlab-tracking.js";
export * from "./planner/planner-intervention.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 * from "./mesh/shared-mesh-state.js";
export { export {
BUILTIN_AGENT_PROMPTS, BUILTIN_AGENT_PROMPTS,
buildPlanningDuplicatePolicyInstruction,
resolveAgentPrompt, resolveAgentPrompt,
buildTriageHeartbeatGuidance, buildTriageHeartbeatGuidance,
buildConciseTriageHeartbeatGuidance, 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" }); 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 () => { it("concurrent identical submissions keep one canonical row", async () => {
const { app, tasks } = buildApp(); const { app, tasks } = buildApp();
const body = JSON.stringify({ title: TITLE, description: DESCRIPTION }); 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 recordActivity = vi.fn().mockResolvedValue(undefined);
const store: Partial<TaskStore> = { 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 }) => { 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 windowMs = Math.max(1, Math.min(300_000, Math.trunc(options?.windowMs ?? 60_000)));
const cutoff = Date.now() - windowMs; const cutoff = Date.now() - windowMs;
@@ -158,6 +160,23 @@ describe("task duplicate detection routes", () => {
expect((res.body as { matches: unknown[] }).matches).toEqual([]); 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 () => { it("POST /tasks returns 409 when duplicate exists without acknowledgement", async () => {
const { app } = buildApp([ const { app } = buildApp([
createTaskFixture({ id: "FN-12", title: "Duplicate warning", description: "Warn before task creation", column: "todo" }), 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); 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([ 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( const res = await performRequest(

View File

@@ -175,6 +175,42 @@ describe("routes /api/tasks explicit duplicate marker", () => {
expect(tasks).toHaveLength(2); 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 () => { 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 canonical = mkTask({ id: "FN-42", title: "Canonical", description: "Existing canonical task", column: "todo" });
const { app, tasks } = buildApp([canonical]); const { app, tasks } = buildApp([canonical]);

View File

@@ -30,8 +30,12 @@ function buildApp(seed: Task[]) {
const tasks = [...seed]; const tasks = [...seed];
const runtimeLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; const runtimeLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
const store: Partial<TaskStore> = { const store: Partial<TaskStore> = {
searchTasks: vi.fn().mockResolvedValue(tasks), searchTasks: vi.fn().mockImplementation(async (_query: string, options?: { includeArchived?: boolean }) =>
listTasks: vi.fn().mockResolvedValue(tasks), 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) => findRecentTasksByContentFingerprint: vi.fn().mockImplementation(async (fingerprint: string) =>
tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fingerprint), tasks.filter((task) => task.source?.sourceMetadata?.contentFingerprint === fingerprint),
), ),
@@ -161,6 +165,32 @@ describe("routes /api/tasks near duplicate", () => {
expect(tasks).toHaveLength(1); 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 () => { it("acknowledgedDuplicates bypasses", async () => {
const { app, tasks } = buildApp([routeSeed]); const { app, tasks } = buildApp([routeSeed]);
const res = await performRequest(app, "POST", "/api/tasks", JSON.stringify({ ...routeIncoming, acknowledgedDuplicates: ["FN-5144"] }), { "content-type": "application/json" }); 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, reconcileDeterministicDuplicate,
extractIntentSignature, extractIntentSignature,
findNearDuplicates, findNearDuplicates,
isNearDuplicateCanonicalInactive,
resolveNearDuplicateCanonicalFlags,
isEphemeralAgent, isEphemeralAgent,
parseExplicitDuplicateMarker, parseExplicitDuplicateMarker,
resolveWorkflowIrForTask, resolveWorkflowIrForTask,
@@ -756,6 +758,7 @@ function buildDuplicateQuery(title: string | undefined, description: string): st
async function computeDuplicateMatches( async function computeDuplicateMatches(
scopedStore: TaskStore, scopedStore: TaskStore,
input: { title?: string; description: string; limit?: number; threshold?: number }, input: { title?: string; description: string; limit?: number; threshold?: number },
classifyBlocker: (canonical: Pick<Task, "id" | "column" | "deletedAt">) => Promise<boolean>,
): Promise<DuplicateMatch[]> { ): Promise<DuplicateMatch[]> {
const query = buildDuplicateQuery(input.title, input.description); const query = buildDuplicateQuery(input.title, input.description);
if (query.length === 0) { if (query.length === 0) {
@@ -767,7 +770,12 @@ async function computeDuplicateMatches(
includeArchived: false, includeArchived: false,
limit: 20, 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, id: task.id,
title: task.title ?? "", title: task.title ?? "",
description: task.description ?? "", 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 { 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 stepPart = input.step ? `step-${input.step}` : "step-na";
const verdictPart = (input.verdict ?? "unknown").toLowerCase(); const verdictPart = (input.verdict ?? "unknown").toLowerCase();
@@ -1408,7 +1424,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
description: description.trim(), description: description.trim(),
limit, limit,
threshold, threshold,
}); }, (canonical) => isDuplicateBlocker(scopedStore, canonical));
res.json({ matches }); res.json({ matches });
return; 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 && typeof source === "object" && "sourceType" in source && typeof (source as { sourceType?: unknown }).sourceType === "string"
? source ? source
: { sourceType: "api" as const }; : { 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 requestedBranchMode = getBranchSelectionMode(branchSelection);
const { branch: normalizedBranch, baseBranch: normalizedBaseBranch, sharedFeatureBranch } = const { branch: normalizedBranch, baseBranch: normalizedBaseBranch, sharedFeatureBranch } =
@@ -1668,7 +1693,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
let matchesAfterAckFilter: DuplicateMatch[] = []; let matchesAfterAckFilter: DuplicateMatch[] = [];
try { try {
if (deterministicGuard.action === "duplicate" && deterministicGuard.existing) { if (
deterministicGuard.action === "duplicate"
&& deterministicGuard.existing
&& await classifyDuplicateBlocker(deterministicGuard.existing)
) {
throw conflict("duplicate_candidates", { throw conflict("duplicate_candidates", {
matches: [{ matches: [{
id: deterministicGuard.existing.id, id: deterministicGuard.existing.id,
@@ -1686,7 +1715,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
: await computeDuplicateMatches(scopedStore, { : await computeDuplicateMatches(scopedStore, {
title: normalizedTitle, title: normalizedTitle,
description: normalizedDescription, description: normalizedDescription,
}); }, classifyDuplicateBlocker);
matchesAfterAckFilter = duplicateMatches.filter((match) => !acknowledgedDuplicateIds.includes(match.id)); matchesAfterAckFilter = duplicateMatches.filter((match) => !acknowledgedDuplicateIds.includes(match.id));
if (matchesAfterAckFilter.length > 0) { if (matchesAfterAckFilter.length > 0) {
throw conflict("duplicate_candidates", { matches: matchesAfterAckFilter }); 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 fullRows = await scopedStore.listTasks({ slim: false, includeArchived: false });
const byId = new Map(fullRows.map((row) => [row.id, row])); const byId = new Map(fullRows.map((row) => [row.id, row]));
const candidateMap = new Map<string, NearDuplicateCandidate>(); 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)) { if (acknowledgedDuplicateIds.includes(row.id)) {
continue; continue;
} }
const full = byId.get(row.id); if (!blocker) {
continue;
}
candidateMap.set(row.id, { candidateMap.set(row.id, {
id: row.id, id: row.id,
title: row.title ?? "", title: row.title ?? "",
@@ -1772,7 +1807,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
(explicitDuplicateMarker ? acknowledgedDuplicateIds.includes(explicitDuplicateMarker.canonicalId) : false); (explicitDuplicateMarker ? acknowledgedDuplicateIds.includes(explicitDuplicateMarker.canonicalId) : false);
if (explicitDuplicateMarker && !explicitMarkerBypassed) { if (explicitDuplicateMarker && !explicitMarkerBypassed) {
const canonical = await scopedStore.getTask(explicitDuplicateMarker.canonicalId).catch(() => null); const canonical = await scopedStore.getTask(explicitDuplicateMarker.canonicalId).catch(() => null);
if (canonical && !canonical.deletedAt) { if (
canonical
&& !canonical.deletedAt
&& await classifyDuplicateBlocker(canonical)
) {
try { try {
// The intake guard runs before createTask, so there is no new task row yet. // 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. // 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, fingerprint: bypassDuplicateCheck === true ? null : contentFingerprint,
windowMs: 60_000, windowMs: 60_000,
logger: runtimeLogger, logger: runtimeLogger,
onDuplicate: async (canonical) =>
await classifyDuplicateBlocker(canonical)
? "archive-created"
: "keep-created",
}); });
if (deterministicReconcile.outcome === "archived") { if (deterministicReconcile.outcome === "archived") {
res.status(200).json(deterministicReconcile.canonical); 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 () => { it("serializes three concurrent paraphrased creates from one parent", async () => {
const tasks: Task[] = []; const tasks: Task[] = [];
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks); 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 () => { it("fn_task_search omits 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.
*/
const { store } = fixture(RENAMED_VOCAB); const { store } = fixture(RENAMED_VOCAB);
const result = await createTaskSearchTool(store).execute("call-3", { query: "a" } as never); const result = await createTaskSearchTool(store).execute("call-3", { query: "a" } as never);
expect(result.content[0].text).toContain("FN-9102"); expect(result.content[0].text).not.toContain("FN-9102");
expect(result.details).toMatchObject({ count: 3 }); expect(result.details).toMatchObject({ count: 2 });
}); });
it("falls back to the legacy terminal pair when the workflow cannot be resolved", async () => { 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( async function createPromptTask(
fx: ReliabilityFixture, 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: FNXC:ExplicitDuplicateMarkerSweep 2026-07-16-11:25:
@@ -25,6 +25,7 @@ async function createPromptTask(
const task = await fx.store.createTask({ const task = await fx.store.createTask({
title: input.title ?? input.id, title: input.title ?? input.id,
description: `${input.id} description`, description: `${input.id} description`,
source: input.sourceType ? { sourceType: input.sourceType } : undefined,
}); });
if (input.column !== "triage") { if (input.column !== "triage") {
await fx.store.moveTask(task.id, input.column); await fx.store.moveTask(task.id, input.column);
@@ -121,6 +122,32 @@ const canRun = hasGit && hasPg;
)).toBe(true); )).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([ it.each([
["user pause", { userPaused: true, paused: true, pausedReason: "manual" }], ["user pause", { userPaused: true, paused: true, pausedReason: "manual" }],
["implicit user pause", { paused: true, pausedReason: null }], ["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 * 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, * 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. * (3) preserving that guidance in FAST_PLANNING_PROMPT. FN-4815 pins this contract.
*/ */
describe("FN-4815 triage duplicate-search regression", () => { 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", () => { it("standard prompt guidance keeps duplicate-search instructions", () => {
expect(TRIAGE_POLICY_PROMPT).toContain("Duplicate check"); expect(TRIAGE_POLICY_PROMPT).toContain("Duplicate check");
expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search"); 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); expect(/Duplicate check[\s\S]{0,700}(done|archived)/i.test(TRIAGE_POLICY_PROMPT)).toBe(true);
}); });
it("fast prompt guidance keeps duplicate-search instructions", () => { it("fast prompt guidance keeps duplicate-search instructions", () => {
expect(FAST_PLANNING_PROMPT).toContain("Duplicate check"); expect(FAST_PLANNING_PROMPT).toContain("Duplicate check");
expect(FAST_PLANNING_PROMPT).toContain("fn_task_search"); 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); 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 scenario = createTriageDuplicateScenario();
const store = scenario.buildMockStore(); const store = scenario.buildMockStore();
const processor = new TriageProcessor(store, "/tmp/root"); const processor = new TriageProcessor(store, "/tmp/root");
@@ -82,11 +84,10 @@ describe("FN-4815 triage duplicate-search regression", () => {
expect(store.searchTasks).toHaveBeenCalledWith(scenario.searchQuery, { expect(store.searchTasks).toHaveBeenCalledWith(scenario.searchQuery, {
slim: true, slim: true,
includeArchived: true, includeArchived: false,
limit: 20, limit: 20,
}); });
expect(output).toContain(`${scenario.doneTask.id} (done):`); expect(output).not.toContain(`${scenario.doneTask.id} (done):`);
expect(output).toContain("(done)");
}); });
it("FN-4726/FN-4734: recently-merged done task touching register-session-diff-routes.ts surfaces via fn_task_search", async () => { 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 () => { it("deletes the duplicate task and records explicit-marker activity", async () => {
const canonical = createTask({ id: "FN-001", title: "Canonical task", column: "todo" }); const canonical = createTask({ id: "FN-001", title: "Canonical task", column: "todo" });
const task = createTask();
const store = createMockStore({ 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({ expect((store as any).deleteTaskIf).toHaveBeenCalledWith("FN-002", expect.any(Function), expect.objectContaining({
removeLineageReferences: true, 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 () => { it("flags and system-pauses duplicates by default instead of deleting", async () => {
const canonical = createTask({ id: "FN-001", column: "todo" }); const canonical = createTask({ id: "FN-001", column: "todo" });
const store = createMockStore({ getTask: vi.fn().mockResolvedValue(canonical) }); const task = createTask();
await expect(runExplicitDuplicateMarker(store, createTask(), "DUPLICATE: FN-001\n")).resolves.toBe(true); 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.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({ paused: true, pausedReason: "duplicate-decision-required" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-001", duplicateSource: "triage-marker" }) })); 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 () => { it("keeps a marker duplicate by clearing its system pause for replanning", async () => {
const canonical = createTask({ id: "FN-001", column: "todo" }); const canonical = createTask({ id: "FN-001", column: "todo" });
const task = createTask(); const task = createTask();
@@ -135,7 +154,9 @@ describe("triage explicit duplicate marker short-circuit", () => {
const task = createTask({ const task = createTask({
sourceMetadata: { nearDuplicateOf: "FN-003", duplicateSource: "triage-marker", nearDuplicateDismissed: true }, 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); 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 }, sourceMetadata: { nearDuplicateOf: "FN-001", duplicateSource: "triage-marker", nearDuplicateDismissed: true },
}); });
const store = createMockStore({ 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), readTaskForMove: vi.fn().mockResolvedValue(task),
}); });
@@ -198,7 +219,7 @@ describe("triage explicit duplicate marker short-circuit", () => {
expect(store.deleteTask).not.toHaveBeenCalled(); 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({ const task = createTask({
sourceMetadata: { sourceMetadata: {
nearDuplicateOf: "FN-001", 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); await expect(runExplicitDuplicateMarker(store, task, "DUPLICATE: FN-001\n")).resolves.toBe(true);
expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({ expect(store.updateTask).toHaveBeenCalledWith("FN-002", expect.objectContaining({
status: "failed", status: "needs-replan",
error: expect.stringContaining("DUPLICATE_REPLAN_EXHAUSTED"), error: null,
sourceMetadataPatch: expect.objectContaining({ 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", nearDuplicateOf: "FN-001",
duplicateSource: "triage-marker",
nearDuplicateDismissed: true, 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; 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 canonicalPlanningPrompt = resolvePlanningPromptFromIr(BUILTIN_CODING_WORKFLOW_IR)!;
const renderedCanonicalPlanningPrompt = renderTriagePolicyPlaceholders(canonicalPlanningPrompt, {}); const renderedCanonicalPlanningPrompt = renderTriagePolicyPlaceholders(canonicalPlanningPrompt, {});
const renderedDefaultTriagePrompt = renderTriagePolicyPlaceholders(resolveAgentPrompt("triage"), {}); const renderedDefaultTriagePrompt = renderTriagePolicyPlaceholders(resolveAgentPrompt("triage"), {});
@@ -200,6 +219,20 @@ describe("triage planning prompt single source", () => {
await expect(captureBasePrompt(task, store)).resolves.toBe(renderedFastPlanningPrompt); 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 () => { it("uses a selected custom workflow planning prompt", async () => {
const task = createTask({ id: "FN-6232-CUSTOM", executionMode: "standard" }); const task = createTask({ id: "FN-6232-CUSTOM", executionMode: "standard" });
const customPrompt = "custom workflow planning prompt"; const customPrompt = "custom workflow planning prompt";

View File

@@ -7664,7 +7664,7 @@ describe("TriageProcessor delegation tools", () => {
expect(toolNames).not.toContain("fn_delegate_task"); 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(); const store = createMockStore();
(store.searchTasks as any).mockResolvedValue([ (store.searchTasks as any).mockResolvedValue([
{ {
@@ -7695,11 +7695,11 @@ describe("TriageProcessor delegation tools", () => {
expect(store.searchTasks).toHaveBeenCalledWith("rebase diff", { expect(store.searchTasks).toHaveBeenCalledWith("rebase diff", {
slim: true, slim: true,
includeArchived: true, includeArchived: false,
limit: 20, limit: 20,
}); });
expect(text).toContain('Search results for "rebase diff" (2):'); expect(text).toContain('Search results for "rebase diff" (1):');
expect(text).toContain("FN-100 (done): Fix rebase merge truncation"); expect(text).not.toContain("FN-100 (done): Fix rebase merge truncation");
}); });
it("fn_task_search filters done tasks when includeDone is false", async () => { 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 // 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 // Standard prompt mentions fn_task_search in duplicate-check guidance
expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search"); expect(TRIAGE_POLICY_PROMPT).toContain("fn_task_search");
// The tool bullet list explicitly states it covers done and archived expect(TRIAGE_POLICY_PROMPT).toContain("includeDone: false");
expect(TRIAGE_POLICY_PROMPT).toContain("including done and archived tasks"); expect(TRIAGE_POLICY_PROMPT).toContain("includeArchived: false");
// Duplicate-check section co-locates fn_task_search with done/archived references // Duplicate-check section co-locates fn_task_search with done/archived references
expect(TRIAGE_POLICY_PROMPT).toContain("done"); expect(TRIAGE_POLICY_PROMPT).toContain("done");
expect(TRIAGE_POLICY_PROMPT).toContain("archived"); 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 // 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 // Fast prompt mentions fn_task_search
expect(FAST_PLANNING_PROMPT).toContain("fn_task_search"); expect(FAST_PLANNING_PROMPT).toContain("fn_task_search");
// Duplicate-check section references done and archived expect(FAST_PLANNING_PROMPT).toContain("includeDone: false");
expect(FAST_PLANNING_PROMPT).toContain("done"); expect(FAST_PLANNING_PROMPT).toContain("includeArchived: false");
expect(FAST_PLANNING_PROMPT).toContain("archived");
// Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived // Defensive regex: duplicate-check guidance must cross-reference fn_task_search with done/archived
expect( expect(
/Duplicate check[\s\S]{0,600}fn_task_search[\s\S]{0,400}(done|archived)/i.test( /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 // 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(); const store = createMockStore();
(store.searchTasks as any).mockResolvedValue([ (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; 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", { expect(store.searchTasks).toHaveBeenCalledWith("rebase truncation", {
slim: true, slim: true,
includeArchived: true, includeArchived: false,
limit: 20, limit: 20,
}); });
// Done results surface in output with the (done): column label expect(text).not.toContain("FN-DONE");
expect(text).toContain("FN-DONE");
expect(text).toContain("(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 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. `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({ const matches = findSameAgentDuplicates({
title: input.title, title: input.title,
description: input.description, description: input.description,
@@ -1263,7 +1263,7 @@ async function findDefinedFeatureBootstrapDuplicate(
task boundary. An archived sibling cannot be a bootstrap canonical because task boundary. An archived sibling cannot be a bootstrap canonical because
claimDefinedFeatureTask rejects non-live task rows. 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 [{ return [{
id: task.id, id: task.id,
title: task.title ?? "", title: task.title ?? "",
@@ -1352,31 +1352,6 @@ export async function resolveTerminalColumnsForTasks(
return (task: Task) => terminalByTaskId.get(task.id)?.has(task.column) === true; 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( export async function createAgentTask(
store: TaskStore, store: TaskStore,
input: TaskCreateInput, input: TaskCreateInput,
@@ -1467,11 +1442,12 @@ export async function createAgentTask(
try { try {
const acknowledged = new Set(options?.acknowledgedDuplicates ?? []); const acknowledged = new Set(options?.acknowledgedDuplicates ?? []);
const candidates = await store.findRecentTasksBySourceParentTaskId(sourceParentTaskId); const candidates = await store.findRecentTasksBySourceParentTaskId(sourceParentTaskId);
const isTerminalCandidate = await resolveTerminalColumnsForTasks(store, candidates);
const matches = findSameAgentDuplicates({ const matches = findSameAgentDuplicates({
title: input.title, title: input.title,
description: input.description, description: input.description,
sourceParentTaskId, sourceParentTaskId,
}, candidates.map((candidate) => ({ }, candidates.filter((candidate) => !isTerminalCandidate(candidate)).map((candidate) => ({
id: candidate.id, id: candidate.id,
title: candidate.title ?? "", title: candidate.title ?? "",
description: candidate.description, description: candidate.description,
@@ -1816,8 +1792,8 @@ export function createTaskSearchTool(store: TaskStore): ToolDefinition {
name: "fn_task_search", name: "fn_task_search",
label: "Search Tasks", label: "Search Tasks",
description: description:
"Keyword search across tasks, including done and archived tasks by default. " + "Keyword search across active tasks by default. " +
"Use for duplicate detection and work discovery before filing new tasks.", "Done and archived history is opt-in and must not be used for duplicate detection.",
parameters: taskSearchParams, parameters: taskSearchParams,
execute: async (_id: string, params: Static<typeof taskSearchParams>) => { execute: async (_id: string, params: Static<typeof taskSearchParams>) => {
const query = params.query.trim(); 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 limit = Math.min(50, Math.max(1, Math.floor(params.limit ?? 20)));
const results = await store.searchTasks(query, { const results = await store.searchTasks(query, {
slim: true, slim: true,
includeArchived: params.includeArchived ?? true, includeArchived: params.includeArchived ?? false,
limit, limit,
}); });
const includeDone = params.includeDone ?? true; const includeDone = params.includeDone ?? false;
const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results); const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results);
const filtered = includeDone ? results : results.filter((task) => !isTerminalResult!(task)); const filtered = includeDone ? results : results.filter((task) => !isTerminalResult!(task));
const lines = filtered.map(formatTaskSummaryLine); const lines = filtered.map(formatTaskSummaryLine);

View File

@@ -8,9 +8,8 @@
* FS-fails, and storms (observed on FN-8704 / inactive FN-8676). * FS-fails, and storms (observed on FN-8704 / inactive FN-8676).
* *
* FNXC:NearDuplicateDetection 2026-08-02-00:46: * FNXC:NearDuplicateDetection 2026-08-02-00:46:
* First clear of an inactive/dismissed-id marker gets one needs-replan chance. * Inactive/dismissed-id markers are cleared for replanning because completed and archived work is
* A second DUPLICATE write for the same already-dismissed canonical parks failed * historical context, never an actionable duplicate, regardless of task provenance.
* (DUPLICATE_REPLAN_EXHAUSTED) so triage stops re-admitting the card forever.
*/ */
/** Log action picked up by triage's needs-replan feedback scanner. */ /** 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 { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path"; import { isAbsolute, join, relative, resolve } from "node:path";
import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, 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, 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, LEGACY_COLUMN_IDS_BY_ROLE,
TERMINAL_ROLES, TERMINAL_ROLES,
resolveProjectColumnsForRoles, resolveProjectColumnsForRoles,
@@ -846,34 +847,6 @@ function hasTerminalInvalidDoneTransition(task: Pick<Task, "error">): boolean {
/** Sentinel for a workflow selection whose READ failed, distinct from "no selection". */ /** Sentinel for a workflow selection whose READ failed, distinct from "no selection". */
const UNREADABLE_WORKFLOW_SELECTION = "\u0000unreadable-workflow-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 { export class SelfHealingManager extends SelfHealingGitEvidence {
// ── Auto-unpause state ────────────────────────────────────────────── // ── Auto-unpause state ──────────────────────────────────────────────
private unpauseTimer: ReturnType<typeof setTimeout> | null = null; 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" const priorClearCount = typeof task.sourceMetadata?.duplicateMarkerClearCount === "number"
? task.sourceMetadata.duplicateMarkerClearCount ? task.sourceMetadata.duplicateMarkerClearCount
: 0; : 0;
const alreadyDismissed = isTriageDuplicateKeepAcknowledged(task.sourceMetadata, marker.canonicalId); const patch = buildMarkerClearedReplanTaskPatch(marker.canonicalId, priorClearCount);
const exhausted = alreadyDismissed || priorClearCount >= 1;
const patch = exhausted
? buildMarkerExhaustedFailedTaskPatch(marker.canonicalId, priorClearCount)
: buildMarkerClearedReplanTaskPatch(marker.canonicalId, priorClearCount);
await this.store.updateTask(task.id, patch); await this.store.updateTask(task.id, patch);
if (typeof this.store.logEntry === "function") { if (typeof this.store.logEntry === "function") {
await Promise.resolve(this.store.logEntry( await Promise.resolve(this.store.logEntry(
task.id, task.id,
exhausted ? buildDuplicateReplanExhaustedError(marker.canonicalId) : TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION, TRIAGE_MARKER_CLEARED_REPLAN_LOG_ACTION,
buildInactiveDuplicateClearFeedback(marker.canonicalId), buildInactiveDuplicateClearFeedback(marker.canonicalId),
)).catch(() => {}); )).catch(() => {});
} }

View File

@@ -23,6 +23,7 @@ import {
getTaskDuplicateLineage, getTaskDuplicateLineage,
parseExplicitDuplicateMarker, parseExplicitDuplicateMarker,
resolveAgentPrompt, resolveAgentPrompt,
buildPlanningDuplicatePolicyInstruction,
builtinSeamPrompt, builtinSeamPrompt,
renderTriagePolicyPlaceholders, renderTriagePolicyPlaceholders,
resolveEffectiveSettingsDetailed, resolveEffectiveSettingsDetailed,
@@ -44,7 +45,8 @@ import {
computePlanApprovalFingerprint, computePlanApprovalFingerprint,
extractIntentSignature, extractIntentSignature,
findNearDuplicates, findNearDuplicates,
isNearDuplicateCanonicalInactive, resolveColumnFlags, isNearDuplicateCanonicalInactive,
resolveNearDuplicateCanonicalFlags,
detectImageMimeFromBytes, detectImageMimeFromBytes,
applyFrontendUxCriteria, applyFrontendUxCriteria,
applyOriginalDescription, applyOriginalDescription,
@@ -355,35 +357,6 @@ no longer has.
*/ */
const LEGACY_PLANNER_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]); 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. 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 // fast prompts. Fast mode currently has no policy placeholders, making
// this a no-op there while still guaranteeing no dangling token leaks. // this a no-op there while still guaranteeing no dangling token leaks.
const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, triagePolicySettings); const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, triagePolicySettings);
const duplicatePolicyInstruction = buildPlanningDuplicatePolicyInstruction();
const triageLayers = buildPromptLayers({ const triageLayers = buildPromptLayers({
basePrompt: renderedBasePrompt, basePrompt: renderedBasePrompt,
goalContext: triageGoalResolution.goalContext, goalContext: triageGoalResolution.goalContext,
agentInstructions: [ agentInstructions: [
triageIdentitySection, triageIdentitySection,
duplicatePolicyInstruction,
triageInstructions, triageInstructions,
isResearchToolSurfaceEnabled(settings) isResearchToolSurfaceEnabled(settings)
? getResearchGuidanceForSurface("triage") ? getResearchGuidanceForSurface("triage")
@@ -3673,8 +3648,8 @@ export class TriageProcessor {
name: "fn_task_search", name: "fn_task_search",
label: "Search Tasks", label: "Search Tasks",
description: description:
"Keyword search across tasks, including done and archived tasks by default. " + "Keyword search across active tasks by default. " +
"Use for duplicate detection before filing a new task.", "Done and archived history is opt-in and must not be used for duplicate detection.",
parameters: taskSearchParams, parameters: taskSearchParams,
execute: async ( execute: async (
_callId: string, _callId: string,
@@ -3689,10 +3664,10 @@ export class TriageProcessor {
} }
const results = await store.searchTasks(query, { const results = await store.searchTasks(query, {
slim: true, slim: true,
includeArchived: params.includeArchived ?? true, includeArchived: params.includeArchived ?? false,
limit: params.limit ?? 20, limit: params.limit ?? 20,
}); });
const includeDone = params.includeDone ?? true; const includeDone = params.includeDone ?? false;
const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results); const isTerminalResult = includeDone ? undefined : await resolveTerminalColumnsForTasks(store, results);
const filtered = includeDone const filtered = includeDone
? results ? results
@@ -4311,16 +4286,14 @@ export class TriageProcessor {
if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined, canonicalFlags)) { if (isNearDuplicateCanonicalInactive(canonicalTask ?? undefined, canonicalFlags)) {
if (canClearInactiveMarker) { if (canClearInactiveMarker) {
/* /*
First inactive clear: replan once with dismissal stamped. Completed and archived work is historical context, never an accepted duplicate verdict.
Re-emit of the same dismissed inactive id (or clearCount>=1): park failed so Clear the marker and require a fresh plan regardless of how the new task was created.
triage eligibility (status:failed) stops the FN-8704 forever-loop.
*/ */
const alreadyDismissed = fusionCore.isTriageDuplicateKeepAcknowledged(liveMeta, canonicalId);
await this.clearDuplicateMarkerForReplan( await this.clearDuplicateMarkerForReplan(
task, task,
canonicalId, canonicalId,
buildInactiveDuplicateClearFeedback(canonicalId), buildInactiveDuplicateClearFeedback(canonicalId),
{ exhausted: alreadyDismissed || priorClearCount >= 1, priorClearCount }, { exhausted: false, priorClearCount },
); );
} }
return; return;