diff --git a/.changeset/prevent-cross-parent-diagnostic-duplicates.md b/.changeset/prevent-cross-parent-diagnostic-duplicates.md new file mode 100644 index 0000000000..b39bb2a220 --- /dev/null +++ b/.changeset/prevent-cross-parent-diagnostic-duplicates.md @@ -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. diff --git a/packages/core/src/__tests__/duplicate-intake.test.ts b/packages/core/src/__tests__/duplicate-intake.test.ts index 2985f69311..1f110798c3 100644 --- a/packages/core/src/__tests__/duplicate-intake.test.ts +++ b/packages/core/src/__tests__/duplicate-intake.test.ts @@ -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( { diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts index 0fc0a92325..6143bf6296 100644 --- a/packages/core/src/duplicate-intake.ts +++ b/packages/core/src/duplicate-intake.ts @@ -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): { 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): string | null { + return computeCrossParentDiagnosticClaim(input)?.id ?? null; +} + /** * Find candidate tasks that look like duplicates spawned by the same caller. * diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index c3f56d76f6..97f1e7f301 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -725,6 +725,8 @@ export type { TaskDependencyMutation } from "./store.js"; export { findSameAgentDuplicates, computeParentIntentClaimId, + computeCrossParentDiagnosticClaim, + computeCrossParentDiagnosticClaimId, archiveAsSameAgentDuplicate, flagSameAgentDuplicate, flagTriageDuplicate, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 16c3ea5ccc..1851fde8c2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -768,6 +768,8 @@ export type { TaskDependencyMutation } from "./store.js"; export { findSameAgentDuplicates, computeParentIntentClaimId, + computeCrossParentDiagnosticClaim, + computeCrossParentDiagnosticClaimId, archiveAsSameAgentDuplicate, flagSameAgentDuplicate, flagTriageDuplicate, diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index 135aa5566e..682d4f2792 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -14,6 +14,8 @@ function createMockAgentStore(overrides: Partial = {}): AgentStore { function createMockTaskStore(overrides: Partial = {}): 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); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 8130f4a5de..847d0aea0e 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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 ? {