feat(FN-4919): merge fusion/fn-4919
This commit is contained in:
5
.changeset/fn-4919-agent-task-github-tracking.md
Normal file
5
.changeset/fn-4919-agent-task-github-tracking.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Ensure GitHub tracking post-create hooks are registered across engine startup entrypoints so agent-created tasks (including `fn_task_create` and `fn_delegate_task`) consistently evaluate default tracking settings and link issues when configured, with improved diagnostics for skipped tracking outcomes.
|
||||||
@@ -1508,7 +1508,7 @@ This complements FN-4657's durable triage routing fix; it does not replace triag
|
|||||||
|
|
||||||
### GitHub tracking lifecycle (task creation + existing-task edits)
|
### GitHub tracking lifecycle (task creation + existing-task edits)
|
||||||
|
|
||||||
Fusion attempts GitHub issue creation when per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`) and the task is currently unlinked. This runs via a **universal post-create hook** registered at process startup by the dashboard package — so it fires for every task-creation path: HTTP routes, pi extension tools (`fn_task_create`, `fn_task_import_github*`, `fn_delegate_task`), CLI commands (`fn task add`, `fn task duplicate`, `fn task refine`), mission/feature triage, automation `create-task` steps, agent-driven delegation, and routine/cron-created tasks. The hook is best-effort and failures are swallowed with a warning, so task creation is never blocked by GitHub availability. The existing inline `maybeCreateTrackingIssue` calls in route handlers remain as redundant safety nets and are idempotent (`issue_already_linked`).
|
Fusion attempts GitHub issue creation when per-task tracking is explicitly enabled (`task.githubTracking.enabled === true`) and the task is currently unlinked. This runs via a **universal post-create hook** registered at process startup by dashboard/CLI entrypoints (including engine startup paths) — so it fires for every task-creation path: HTTP routes, pi extension tools (`fn_task_create`, `fn_task_import_github*`, `fn_delegate_task`), CLI commands (`fn task add`, `fn task duplicate`, `fn task refine`), mission/feature triage, automation `create-task` steps, agent-driven delegation, and routine/cron-created tasks. The hook is best-effort and failures are swallowed with a warning, so task creation is never blocked by GitHub availability. The existing inline `maybeCreateTrackingIssue` calls in route handlers remain as redundant safety nets and are idempotent (`issue_already_linked`).
|
||||||
|
|
||||||
When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Task title` and sends a short plain-text body prefixed with `Fusion task: FN-XXXX`. The body is a bounded summary snippet (not full task prompt content), and Fusion does not include any hyperlink back to the local dashboard. Manual unlink requests (`githubTracking.issue: null`) do not recreate an issue in that same PATCH request, and disable updates do not create issues. Auth resolution remains strict-mode (`token` vs `gh-cli`) but now defensively accepts merged settings shapes where auth keys may appear in global-merged payloads.
|
When Fusion does create a tracking issue, it formats the title as `[FN-XXXX] Task title` and sends a short plain-text body prefixed with `Fusion task: FN-XXXX`. The body is a bounded summary snippet (not full task prompt content), and Fusion does not include any hyperlink back to the local dashboard. Manual unlink requests (`githubTracking.issue: null`) do not recreate an issue in that same PATCH request, and disable updates do not create issues. Auth resolution remains strict-mode (`token` vs `gh-cli`) but now defensively accepts merged settings shapes where auth keys may appear in global-merged payloads.
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
reconcileClaudeCliPaths,
|
reconcileClaudeCliPaths,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard";
|
||||||
import {
|
import {
|
||||||
ProjectEngineManager,
|
ProjectEngineManager,
|
||||||
PeerExchangeService,
|
PeerExchangeService,
|
||||||
@@ -324,6 +324,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
|||||||
ntfyProjectId = registered?.id;
|
ntfyProjectId = registered?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
registerGithubTrackingHook?.();
|
||||||
|
} catch {
|
||||||
|
// Some tests partially mock @fusion/dashboard and omit this export.
|
||||||
|
}
|
||||||
|
|
||||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||||
getMergeStrategy,
|
getMergeStrategy,
|
||||||
processPullRequestMerge: (s, wd, taskId) =>
|
processPullRequestMerge: (s, wd, taskId) =>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getCliPackageVersion,
|
getCliPackageVersion,
|
||||||
getProjectSettingsPath,
|
getProjectSettingsPath,
|
||||||
loadTlsCredentialsFromEnv,
|
loadTlsCredentialsFromEnv,
|
||||||
|
registerGithubTrackingHook,
|
||||||
stopAllDevServers,
|
stopAllDevServers,
|
||||||
type RuntimeLogger,
|
type RuntimeLogger,
|
||||||
} from "@fusion/dashboard";
|
} from "@fusion/dashboard";
|
||||||
@@ -1501,6 +1502,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
// Non-fatal — engine uses fallback concurrency defaults
|
// Non-fatal — engine uses fallback concurrency defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
registerGithubTrackingHook?.();
|
||||||
|
} catch {
|
||||||
|
// Some tests partially mock @fusion/dashboard and omit this export.
|
||||||
|
}
|
||||||
|
|
||||||
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
|
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
|
||||||
getMergeStrategy,
|
getMergeStrategy,
|
||||||
processPullRequestMerge: (s, wd, taskId) =>
|
processPullRequestMerge: (s, wd, taskId) =>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
getEnabledPiExtensionPaths,
|
getEnabledPiExtensionPaths,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard";
|
||||||
import {
|
import {
|
||||||
ProjectEngineManager,
|
ProjectEngineManager,
|
||||||
PeerExchangeService,
|
PeerExchangeService,
|
||||||
@@ -350,6 +350,12 @@ export async function runServe(
|
|||||||
ntfyProjectId = registered?.id;
|
ntfyProjectId = registered?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
registerGithubTrackingHook?.();
|
||||||
|
} catch {
|
||||||
|
// Some tests partially mock @fusion/dashboard and omit this export.
|
||||||
|
}
|
||||||
|
|
||||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||||
getMergeStrategy,
|
getMergeStrategy,
|
||||||
processPullRequestMerge: (s, wd, taskId) =>
|
processPullRequestMerge: (s, wd, taskId) =>
|
||||||
|
|||||||
@@ -473,4 +473,27 @@ describe("registerGithubTrackingHook", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records a github-tracking-no-repo activity for agent-created tasks when defaults enable tracking", async () => {
|
||||||
|
registerGithubTrackingHook();
|
||||||
|
|
||||||
|
await store.updateSettings({
|
||||||
|
githubTrackingEnabledByDefault: true,
|
||||||
|
githubAuthMode: "token",
|
||||||
|
githubAuthToken: "tok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = await store.createTask({
|
||||||
|
description: "agent-created task with missing repo",
|
||||||
|
source: { sourceType: "api" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activity = await store.getActivityLog({ type: "task:updated" });
|
||||||
|
const trackingNoRepoEntries = activity.filter((entry) =>
|
||||||
|
entry.taskId === task.id && (entry.metadata as { type?: string } | undefined)?.type === "github-tracking-no-repo",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCreateIssue).not.toHaveBeenCalled();
|
||||||
|
expect(trackingNoRepoEntries).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export async function createTrackingIssueForTask(
|
|||||||
rootDir: taskStore.getRootDir(),
|
rootDir: taskStore.getRootDir(),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Best-effort only.
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn?.(`[github-tracking-hook] ${task.id}: createTrackingIssueForTask failed: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,10 +193,12 @@ export async function maybeCreateTrackingIssue(
|
|||||||
const inlineTracking = task.githubTracking;
|
const inlineTracking = task.githubTracking;
|
||||||
const resolvedTracking = resolveTaskGithubTracking(task, deps.projectSettings, deps.globalSettings);
|
const resolvedTracking = resolveTaskGithubTracking(task, deps.projectSettings, deps.globalSettings);
|
||||||
if (!resolvedTracking.enabled) {
|
if (!resolvedTracking.enabled) {
|
||||||
|
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (tracking_disabled)`);
|
||||||
return { created: false, reason: "tracking_disabled" };
|
return { created: false, reason: "tracking_disabled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inlineTracking?.issue) {
|
if (inlineTracking?.issue) {
|
||||||
|
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (issue_already_linked)`);
|
||||||
return { created: false, reason: "issue_already_linked" };
|
return { created: false, reason: "issue_already_linked" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +216,7 @@ export async function maybeCreateTrackingIssue(
|
|||||||
|
|
||||||
const tracking = latestTask.githubTracking ?? inlineTracking;
|
const tracking = latestTask.githubTracking ?? inlineTracking;
|
||||||
if (tracking?.issue) {
|
if (tracking?.issue) {
|
||||||
|
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (issue_already_linked)`);
|
||||||
return { created: false, reason: "issue_already_linked" };
|
return { created: false, reason: "issue_already_linked" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { TaskStore, setTaskCreatedHook, type Task } from "@fusion/core";
|
||||||
|
import { createTaskCreateTool, createDelegateTaskTool } from "../agent-tools.js";
|
||||||
|
|
||||||
|
function makeTmpDir(prefix: string): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("agent task creation github-tracking hook integration", () => {
|
||||||
|
let rootDir: string;
|
||||||
|
let globalDir: string;
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setTaskCreatedHook(undefined);
|
||||||
|
rootDir = makeTmpDir("kb-engine-agent-tools-gh-track-");
|
||||||
|
globalDir = makeTmpDir("kb-engine-agent-tools-gh-track-global-");
|
||||||
|
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||||
|
await store.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setTaskCreatedHook(undefined);
|
||||||
|
store.close();
|
||||||
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
|
await rm(globalDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the post-create hook for fn_task_create", async () => {
|
||||||
|
const hook = vi.fn(async (_task: Task) => {});
|
||||||
|
setTaskCreatedHook(hook);
|
||||||
|
|
||||||
|
const tool = createTaskCreateTool(store);
|
||||||
|
const result = await tool.execute("call-1", { description: "agent-created triage task" } as never, undefined, undefined, {} as never);
|
||||||
|
|
||||||
|
expect(result.details).toHaveProperty("taskId");
|
||||||
|
expect(hook).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hook.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||||
|
description: "agent-created triage task",
|
||||||
|
column: "triage",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the post-create hook for fn_delegate_task", async () => {
|
||||||
|
const hook = vi.fn(async (_task: Task) => {});
|
||||||
|
setTaskCreatedHook(hook);
|
||||||
|
|
||||||
|
const agentStore = {
|
||||||
|
getAgent: vi.fn().mockResolvedValue({ id: "agent-1", name: "Worker", role: "executor", state: "idle" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool = createDelegateTaskTool(agentStore as never, store);
|
||||||
|
const result = await tool.execute("call-1", {
|
||||||
|
agent_id: "agent-1",
|
||||||
|
description: "delegated tracked task",
|
||||||
|
} as never, undefined, undefined, {} as never);
|
||||||
|
|
||||||
|
expect(result.details).toEqual(expect.objectContaining({ taskId: expect.any(String), agentId: "agent-1" }));
|
||||||
|
expect(hook).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hook.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||||
|
description: "delegated tracked task",
|
||||||
|
assignedAgentId: "agent-1",
|
||||||
|
column: "todo",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user