feat(FN-5060): complete Step 5 — wire engine and mission dedup guard

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 17:36:19 -07:00
committed by gsxdsm
parent 6365fceee4
commit a54496f45d
5 changed files with 154 additions and 34 deletions

View File

@@ -7,6 +7,7 @@ import {
buildQmdAgentMemorySearchArgs,
createMemoryTools,
createTaskCreateTool,
createAgentTask,
createDelegateTaskTool,
createTaskLogTool,
createTaskLogToolWithContext,
@@ -64,6 +65,18 @@ vi.mock("node:child_process", async () => {
};
});
beforeEach(() => {
vi.spyOn(core, "runDeterministicDuplicateGuard").mockResolvedValue({
action: "proceed",
fingerprint: null,
releaseLock: vi.fn(),
});
vi.spyOn(core, "reconcileDeterministicDuplicate").mockImplementation(async (_store, args) => ({
outcome: "kept",
canonical: args.createdTask,
}));
});
describe("tool availability helpers", () => {
it("treats research surface as disabled when researchView experimental flag is off", () => {
expect(getResearchToolSurfaceStatus({ experimentalFeatures: { researchView: false } } as any)).toEqual({
@@ -160,6 +173,27 @@ describe("createTaskCreateTool", () => {
source: { sourceType: "agent_heartbeat", sourceAgentId: "agent-123", sourceRunId: undefined },
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
});
it("createAgentTask returns existing task on deterministic duplicate", async () => {
const created = { id: "FN-200", description: "duplicate", dependencies: [], column: "triage" };
vi.spyOn(core, "runDeterministicDuplicateGuard")
.mockResolvedValueOnce({ action: "proceed", fingerprint: "fp", releaseLock: vi.fn() })
.mockResolvedValueOnce({ action: "duplicate", fingerprint: "fp", existing: created as any, releaseLock: vi.fn() });
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
getRootDir: vi.fn().mockReturnValue("/tmp"),
createTask: vi.fn().mockResolvedValue(created),
};
const first = await createAgentTask(store as any, { description: "duplicate" } as any);
const second = await createAgentTask(store as any, { description: "duplicate" } as any);
expect(first.task.id).toBe("FN-200");
expect(second.task.id).toBe("FN-200");
expect(second.wasDuplicate).toBe(true);
expect(store.createTask).toHaveBeenCalledTimes(1);
});
});
describe("createDelegateTaskTool", () => {

View File

@@ -12,7 +12,7 @@ import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js";
@@ -601,21 +601,60 @@ export async function createAgentTask(
store: TaskStore,
input: TaskCreateInput,
options?: AgentTaskCreationOptions,
): Promise<Awaited<ReturnType<TaskStore["createTask"]>>> {
): Promise<{ task: Awaited<ReturnType<TaskStore["createTask"]>>; wasDuplicate: boolean }> {
const settings = typeof (store as { getSettings?: unknown }).getSettings === "function"
? await store.getSettings()
: {} as Settings;
const rootDir = options?.rootDir;
return store.createTask(input, {
settings: { autoSummarizeTitles: settings.autoSummarizeTitles === true },
onSummarize: rootDir
? async (description: string) => {
const resolved = resolveTitleSummarizerSettingsModel(settings);
return summarizeTitle(description, rootDir, resolved.provider, resolved.modelId);
}
: undefined,
const guard = await runDeterministicDuplicateGuard(store, {
title: input.title,
description: input.description,
}, {
lockScope: rootDir ?? store.getRootDir?.() ?? "agent-tools",
bypass: input.bypassDuplicateCheck === true,
acknowledgedDuplicates: input.acknowledgedDuplicates,
logger: log,
});
try {
if (guard.action === "duplicate" && guard.existing) {
return { task: guard.existing, wasDuplicate: true };
}
const sourceMetadata = {
...(input.source?.sourceMetadata ?? {}),
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
};
const nextSource = input.source || Object.keys(sourceMetadata).length > 0
? {
...(input.source ?? {}),
sourceMetadata: Object.keys(sourceMetadata).length > 0 ? sourceMetadata : undefined,
}
: undefined;
const createdTask = await store.createTask({
...input,
source: nextSource,
}, {
settings: { autoSummarizeTitles: settings.autoSummarizeTitles === true },
onSummarize: rootDir
? async (description: string) => {
const resolved = resolveTitleSummarizerSettingsModel(settings);
return summarizeTitle(description, rootDir, resolved.provider, resolved.modelId);
}
: undefined,
});
const reconcile = await reconcileDeterministicDuplicate(store, {
createdTask,
fingerprint: guard.fingerprint,
logger: log,
});
return { task: reconcile.canonical, wasDuplicate: reconcile.outcome === "archived" };
} finally {
guard.releaseLock();
}
}
/**
@@ -640,7 +679,7 @@ export function createTaskCreateTool(
parameters: taskCreateParams,
execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
try {
const task = await createAgentTask(store, {
const { task, wasDuplicate } = await createAgentTask(store, {
description: params.description,
dependencies: params.dependencies,
column: "triage",
@@ -655,7 +694,7 @@ export function createTaskCreateTool(
return {
content: [{
type: "text" as const,
text: `Created ${task.id}: ${params.description}${deps}`,
text: `${wasDuplicate ? "Linked existing" : "Created"} ${task.id}: ${params.description}${deps}`,
}],
details: { taskId: task.id },
};
@@ -1798,7 +1837,7 @@ export function createDelegateTaskTool(
try {
// Create task assigned to the target agent
const task = await createAgentTask(taskStore, {
const { task } = await createAgentTask(taskStore, {
description: params.description,
dependencies: params.dependencies,
column: "todo",

View File

@@ -1876,7 +1876,7 @@ export class TriageProcessor {
parentTask = undefined;
}
const newTask = await createAgentTask(store, {
const { task: newTask, wasDuplicate } = await createAgentTask(store, {
title: params.title,
description: params.description,
dependencies: validDeps,
@@ -1900,7 +1900,7 @@ export class TriageProcessor {
content: [
{
type: "text" as const,
text: `Created child task ${newTask.id}: ${params.title || params.description.slice(0, 60)}`,
text: `${wasDuplicate ? "Linked existing child task" : "Created child task"} ${newTask.id}: ${params.title || params.description.slice(0, 60)}`,
},
],
details: { taskId: newTask.id },