fix(FN-8785): ignore completed duplicate history
Fusion-Task-Id: FN-8785
This commit is contained in:
7
.changeset/fn-8785-user-duplicate-history.md
Normal file
7
.changeset/fn-8785-user-duplicate-history.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user