fix: prevent duplicate diagnostic task creation
Serialize cross-parent diagnostic claims at project scope and reuse a recent active canonical task. Keep ordinary, completed, and path-distinct work independent.
This commit is contained in:
7
.changeset/prevent-cross-parent-diagnostic-duplicates.md
Normal file
7
.changeset/prevent-cross-parent-diagnostic-duplicates.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Prevent agents from filing duplicate active diagnostic follow-ups discovered by different tasks.
|
||||
category: fix
|
||||
dev: Adds narrow code-identifier-based cross-parent deduplication while preserving distinct and completed work.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { computeParentIntentClaimId, findSameAgentDuplicates, flagSameAgentDuplicate } from "../duplicate-intake.js";
|
||||
import { computeCrossParentDiagnosticClaimId, computeParentIntentClaimId, findSameAgentDuplicates, flagSameAgentDuplicate } from "../duplicate-intake.js";
|
||||
import type { TaskStore } from "../store.js";
|
||||
|
||||
describe("findSameAgentDuplicates", () => {
|
||||
@@ -122,6 +122,44 @@ describe("findSameAgentDuplicates", () => {
|
||||
expect(claim("Add new support")).toMatch(/^agent-parent-intent:FN-8277:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("derives one cross-parent claim for the recently repeated diagnostic", () => {
|
||||
const descriptions = [
|
||||
"Investigate and repair dashboard typecheck failure: app/utils/capture-screenshot.ts imports unresolved `html2canvas`, causing `pnpm verify:fast` to fail.",
|
||||
"Fix dashboard typecheck failure: app/utils/capture-screenshot.ts cannot resolve the html2canvas module during pnpm verify:fast.",
|
||||
"Fix dashboard typecheck failure: packages/dashboard/app/utils/capture-screenshot.ts imports unresolved html2canvas. Add or correctly wire the dependency.",
|
||||
"Fix dashboard typecheck missing html2canvas dependency/import in packages/dashboard/app/utils/capture-screenshot.ts (TS2307 observed during pnpm verify:fast).",
|
||||
"Fix dashboard typecheck dependency resolution for app/utils/capture-screenshot.ts: Cannot find module 'html2canvas' during pnpm verify:fast.",
|
||||
"Restore the missing `html2canvas` dependency declaration/lock entry for dashboard screenshot capture so @fusion/dashboard typecheck passes.",
|
||||
];
|
||||
|
||||
const claims = descriptions.map((description) => computeCrossParentDiagnosticClaimId({ description }));
|
||||
|
||||
expect(new Set(claims).size).toBe(1);
|
||||
expect(claims[0]).toMatch(/^agent-diagnostic-intent:/);
|
||||
});
|
||||
|
||||
it("does not globally claim ordinary work or unrelated work on the same module", () => {
|
||||
expect(computeCrossParentDiagnosticClaimId({
|
||||
description: "Add screenshot upload support using html2canvas",
|
||||
})).toBeNull();
|
||||
expect(computeCrossParentDiagnosticClaimId({
|
||||
description: "Improve html2canvas capture performance",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps diagnostic paths distinct when they share a basename", () => {
|
||||
const first = computeCrossParentDiagnosticClaimId({
|
||||
description: "Fix unresolved packages/alpha/index.ts typecheck failure.",
|
||||
});
|
||||
const second = computeCrossParentDiagnosticClaimId({
|
||||
description: "Fix unresolved packages/beta/index.ts typecheck failure.",
|
||||
});
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBeNull();
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("does not match sibling with different parent task", () => {
|
||||
const matches = findSameAgentDuplicates(
|
||||
{
|
||||
|
||||
@@ -78,6 +78,49 @@ export function computeParentIntentClaimId(input: SameAgentDuplicateInput): stri
|
||||
return parentId && anchor ? `agent-parent-intent:${parentId}:${anchor}` : null;
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_ACTION_PATTERN = /\b(?:fix|investigate|repair|resolve|restore)\b/i;
|
||||
const DIAGNOSTIC_FAILURE_PATTERN = /\b(?:cannot|can't|error|fail(?:ed|ure|s)?|missing|ts\d{4}|typecheck|unresolved)\b/i;
|
||||
const DIAGNOSTIC_OBJECT_PATTERNS = [
|
||||
/\b(?:missing|unresolved)\s+([`'"]?[@a-z0-9][@a-z0-9._/-]*[`'"]?)/gi,
|
||||
/\b(?:cannot|can't)\s+(?:find|resolve)(?:\s+module)?\s+(?:the\s+)?([`'"]?[@a-z0-9][@a-z0-9._/-]*[`'"]?)/gi,
|
||||
];
|
||||
const IGNORED_DIAGNOSTIC_OBJECTS = new Set(["a", "an", "dependency", "module", "the", "type", "types"]);
|
||||
|
||||
function normalizeDiagnosticObject(value: string): string | null {
|
||||
const normalized = value.toLowerCase().replace(/^[`'"]+|[`'".,;:]+$/g, "");
|
||||
if (!normalized || IGNORED_DIAGNOSTIC_OBJECTS.has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
// Only code-shaped objects are safe for global convergence. Generic prose
|
||||
// such as "missing button" must remain scoped to its parent task.
|
||||
return /[0-9@./_-]/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable claim for an active diagnostic follow-up that may be discovered by
|
||||
* several unrelated parent tasks. This is intentionally narrower than general
|
||||
* near-duplicate matching: only repair-shaped failures with a code identifier
|
||||
* can converge globally.
|
||||
*/
|
||||
export function computeCrossParentDiagnosticClaim(input: Pick<SameAgentDuplicateInput, "title" | "description">): { id: string; searchTerm: string } | null {
|
||||
const text = `${input.title ?? ""}\n${input.description}`;
|
||||
if (!DIAGNOSTIC_ACTION_PATTERN.test(text) || !DIAGNOSTIC_FAILURE_PATTERN.test(text)) return null;
|
||||
|
||||
const objects = DIAGNOSTIC_OBJECT_PATTERNS.flatMap((pattern) =>
|
||||
[...text.matchAll(pattern)]
|
||||
.map((match) => normalizeDiagnosticObject(match[1] ?? ""))
|
||||
.filter((value): value is string => value !== null),
|
||||
);
|
||||
const diagnosticObject = [...new Set(objects)].sort()[0];
|
||||
if (!diagnosticObject) return null;
|
||||
const fingerprint = computeContentFingerprint({ title: "agent-diagnostic-intent", description: diagnosticObject });
|
||||
return fingerprint ? { id: `agent-diagnostic-intent:${fingerprint}`, searchTerm: diagnosticObject } : null;
|
||||
}
|
||||
|
||||
export function computeCrossParentDiagnosticClaimId(input: Pick<SameAgentDuplicateInput, "title" | "description">): string | null {
|
||||
return computeCrossParentDiagnosticClaim(input)?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find candidate tasks that look like duplicates spawned by the same caller.
|
||||
*
|
||||
|
||||
@@ -725,6 +725,8 @@ export type { TaskDependencyMutation } from "./store.js";
|
||||
export {
|
||||
findSameAgentDuplicates,
|
||||
computeParentIntentClaimId,
|
||||
computeCrossParentDiagnosticClaim,
|
||||
computeCrossParentDiagnosticClaimId,
|
||||
archiveAsSameAgentDuplicate,
|
||||
flagSameAgentDuplicate,
|
||||
flagTriageDuplicate,
|
||||
|
||||
@@ -768,6 +768,8 @@ export type { TaskDependencyMutation } from "./store.js";
|
||||
export {
|
||||
findSameAgentDuplicates,
|
||||
computeParentIntentClaimId,
|
||||
computeCrossParentDiagnosticClaim,
|
||||
computeCrossParentDiagnosticClaimId,
|
||||
archiveAsSameAgentDuplicate,
|
||||
flagSameAgentDuplicate,
|
||||
flagTriageDuplicate,
|
||||
|
||||
@@ -14,6 +14,8 @@ function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
|
||||
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
|
||||
getRootDir: vi.fn().mockReturnValue("/project"),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
findRecentTasksBySourceParentTaskId: vi.fn().mockResolvedValue([]),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValue([]),
|
||||
updateTask: vi.fn(),
|
||||
@@ -368,6 +370,92 @@ describe("createDelegateTaskTool", () => {
|
||||
expect(taskStore.moveTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses one active diagnostic follow-up across different parent tasks", async () => {
|
||||
const tasks: Task[] = [];
|
||||
vi.mocked(taskStore.searchTasks).mockImplementation(async () => tasks);
|
||||
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async (parentId) =>
|
||||
tasks.filter((task) => task.sourceParentTaskId === parentId),
|
||||
);
|
||||
vi.mocked(taskStore.createTask).mockImplementation(async (input) => {
|
||||
await Promise.resolve();
|
||||
const now = new Date().toISOString();
|
||||
const created = {
|
||||
id: `FN-${tasks.length + 1}`,
|
||||
description: input.description,
|
||||
dependencies: [],
|
||||
column: "triage" as const,
|
||||
sourceParentTaskId: input.source?.sourceParentTaskId,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as Task;
|
||||
tasks.push(created);
|
||||
return created;
|
||||
});
|
||||
|
||||
const [first, replay] = await Promise.all([
|
||||
createAgentTask(taskStore, {
|
||||
description: "Investigate and repair dashboard typecheck failure: app/utils/capture-screenshot.ts imports unresolved `html2canvas`, causing `pnpm verify:fast` to fail.",
|
||||
}, { sourceTaskId: "FN-8343", rootDir: "/worktrees/FN-8343" }),
|
||||
createAgentTask(taskStore, {
|
||||
description: "Restore the missing `html2canvas` dependency declaration/lock entry for dashboard screenshot capture so @fusion/dashboard typecheck passes.",
|
||||
}, { sourceTaskId: "FN-8348", rootDir: "/worktrees/FN-8348" }),
|
||||
]);
|
||||
|
||||
expect(first.wasDuplicate).toBe(false);
|
||||
expect(replay).toMatchObject({ wasDuplicate: true, task: { id: first.task.id } });
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: expect.objectContaining({
|
||||
sourceMetadata: expect.objectContaining({
|
||||
crossParentDiagnosticClaimId: expect.stringMatching(/^agent-diagnostic-intent:/),
|
||||
}),
|
||||
}),
|
||||
}), expect.anything());
|
||||
});
|
||||
|
||||
it("does not let a completed diagnostic suppress newly required work", async () => {
|
||||
const completed = {
|
||||
id: "FN-DONE",
|
||||
description: "Fix unresolved `html2canvas` typecheck failure.",
|
||||
dependencies: [],
|
||||
column: "done" as const,
|
||||
sourceParentTaskId: "FN-OLD-PARENT",
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
const created = {
|
||||
...completed,
|
||||
id: "FN-NEW",
|
||||
column: "triage" as const,
|
||||
sourceParentTaskId: "FN-NEW-PARENT",
|
||||
} as Task;
|
||||
vi.mocked(taskStore.searchTasks).mockResolvedValue([completed]);
|
||||
vi.mocked(taskStore.createTask).mockResolvedValue(created);
|
||||
|
||||
const result = await createAgentTask(taskStore, {
|
||||
description: "Restore the missing html2canvas dependency so dashboard typecheck passes.",
|
||||
}, { sourceTaskId: "FN-NEW-PARENT" });
|
||||
|
||||
expect(result).toEqual({ task: created, wasDuplicate: false });
|
||||
expect(taskStore.createTask).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails closed when cross-parent diagnostic lookup is unavailable", async () => {
|
||||
vi.mocked(taskStore.searchTasks).mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
await expect(createAgentTask(taskStore, {
|
||||
description: "Fix unresolved `html2canvas` typecheck failure.",
|
||||
}, { sourceTaskId: "FN-PARENT" })).rejects.toThrow("Unable to verify cross-parent diagnostic task uniqueness");
|
||||
|
||||
expect(taskStore.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists option-based parent provenance on the step-session fn_task_create surface", async () => {
|
||||
const tool = createTaskCreateTool(taskStore, undefined, { sourceTaskId: "FN-PARENT", sourceAgentId: "agent-worker" });
|
||||
await tool.execute("call-1", { description: "Capture optional report screenshots" }, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
@@ -16,7 +16,7 @@ import * as fusionCore from "@fusion/core";
|
||||
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core";
|
||||
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core";
|
||||
import { promoteHeldTask } from "./hold-release.js";
|
||||
import { computeParentIntentClaimId, DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, findSameAgentDuplicates, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
||||
import { computeCrossParentDiagnosticClaim, computeCrossParentDiagnosticClaimId, computeParentIntentClaimId, DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, findSameAgentDuplicates, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||
import { ResearchProviderRegistry } from "./research/provider-registry.js";
|
||||
import { ResearchStepRunner } from "./research-step-runner.js";
|
||||
@@ -975,6 +975,13 @@ export async function createAgentTask(
|
||||
const rootDir = options?.rootDir;
|
||||
const sourceParentTaskId = (input.source?.sourceParentTaskId ?? options?.sourceTaskId)?.trim().toUpperCase();
|
||||
const sourceAgentId = input.source?.sourceAgentId ?? options?.sourceAgentId;
|
||||
const crossParentDiagnosticClaim = options?.bypassDuplicateCheck === true
|
||||
? null
|
||||
: computeCrossParentDiagnosticClaim({ title: input.title, description: input.description });
|
||||
const crossParentDiagnosticClaimId = crossParentDiagnosticClaim?.id ?? null;
|
||||
const duplicateLockScope = crossParentDiagnosticClaimId
|
||||
? store.getRootDir?.() ?? rootDir ?? "agent-tools"
|
||||
: rootDir ?? store.getRootDir?.() ?? "agent-tools";
|
||||
const effectiveSource = input.source || sourceParentTaskId || sourceAgentId
|
||||
? {
|
||||
sourceType: input.source?.sourceType ?? "api" as const,
|
||||
@@ -987,11 +994,11 @@ export async function createAgentTask(
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
}, {
|
||||
lockScope: rootDir ?? store.getRootDir?.() ?? "agent-tools",
|
||||
lockScope: duplicateLockScope,
|
||||
bypass: options?.bypassDuplicateCheck === true,
|
||||
acknowledgedDuplicates: options?.acknowledgedDuplicates,
|
||||
serializationKey: sourceParentTaskId ? `parent:${sourceParentTaskId}` : undefined,
|
||||
sourceParentTaskId,
|
||||
serializationKey: crossParentDiagnosticClaimId ?? (sourceParentTaskId ? `parent:${sourceParentTaskId}` : undefined),
|
||||
sourceParentTaskId: crossParentDiagnosticClaimId ? null : sourceParentTaskId,
|
||||
logger: log,
|
||||
});
|
||||
|
||||
@@ -1003,6 +1010,35 @@ export async function createAgentTask(
|
||||
};
|
||||
}
|
||||
|
||||
if (crossParentDiagnosticClaim) {
|
||||
try {
|
||||
const acknowledged = new Set(options?.acknowledgedDuplicates ?? []);
|
||||
const cutoffMs = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const candidates = (await store.searchTasks(crossParentDiagnosticClaim.searchTerm, {
|
||||
slim: true,
|
||||
includeArchived: false,
|
||||
}))
|
||||
.filter((candidate) => candidate.column !== "done" && candidate.column !== "archived")
|
||||
.filter((candidate) => Date.parse(candidate.createdAt) >= cutoffMs)
|
||||
.filter((candidate) => !acknowledged.has(candidate.id))
|
||||
.filter((candidate) => computeCrossParentDiagnosticClaimId({
|
||||
title: candidate.title,
|
||||
description: candidate.description,
|
||||
}) === crossParentDiagnosticClaimId)
|
||||
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
|
||||
const canonical = candidates[0];
|
||||
if (canonical) {
|
||||
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Cross-parent diagnostic duplicate pre-check failed; aborting creation", {
|
||||
crossParentDiagnosticClaimId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error("Unable to verify cross-parent diagnostic task uniqueness", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceParentTaskId && options?.bypassDuplicateCheck !== true) {
|
||||
try {
|
||||
const acknowledged = new Set(options?.acknowledgedDuplicates ?? []);
|
||||
@@ -1037,6 +1073,7 @@ export async function createAgentTask(
|
||||
const sourceMetadata = {
|
||||
...(effectiveSource?.sourceMetadata ?? {}),
|
||||
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
|
||||
...(crossParentDiagnosticClaimId ? { crossParentDiagnosticClaimId } : {}),
|
||||
};
|
||||
const nextSource = effectiveSource
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user