feat(FN-2923): merge fusion/fn-2923

- Add task `source` provenance field tracking how tasks enter the system (e.g. `"manual"`, `"heartbeat"`, `"executor"`, `"triage"`, `"cron"`, `"routine"`)
- Wire `source` into shared create-task tools, executor agent, triage agent, cron runner, and routine runner
- Add `source` to heartbeat task creation in `agent-heartbeat.ts` and mission execution loop in `mission-execution-loop.ts`
- Wire `source` field into dashboard routes (`register-planning-subtask-routes.ts`, `register-task-workflow-routes.ts`) and GitHub integration (`register-git-github.ts`)
- Add `source` to the API layer and task store handlers in the dashboard
- Expose `source` in the `fn` CLI task command and `task-plan` extension tool
- Add integration tests for provenance across engine (agent-tools, agent-heartbeat, cron-runner, triage, pr-comment-handler) and dashboard routes
- Update existing tests to assert the correct source values
- Bulk-revert stale changeset files that were superseded by the v0.9.1 release commit

Commits merged:
- test(FN-2923): complete Step 14 — align tests with task source provenance
- test(FN-2923): complete Steps 12-13 — update and add provenance tests
- feat(FN-2923): complete Step 10 — wire CLI task provenance
- feat(FN-2923): complete Step 9 — wire dashboard UI and API source fields
- feat(FN-2923): complete Step 8 — wire dashboard route provenance
- feat(FN-2923): complete Step 7 — wire remaining engine provenance
- feat(FN-2923): complete Step 6 — add routine create-task provenance
- feat(FN-2923): complete Step 5 — add cron create-task provenance
- feat(FN-2923): complete Step 4 — add triage subtask source
- feat(FN-2923): complete Step 3 — add executor create-task source
- feat(FN-2923): complete Step 2 — add heartbeat task provenance
- feat(FN-2923): complete Step 1 — add provenance to shared create-task tools
- feat(FN-2945): merge fusion/fn-2945
- chore(release): v0.9.1

Files changed:
.changeset/active-agents-no-stuck-connecting.md    | 13 ---
 .changeset/active-agents-panel-hoist-heartbeat.md  | 13 ---
 .changeset/fix-agent-heartbeat-terminal-links.md   |  5 --
 .changeset/fix-experimental-defaults.md            |  5 --
 .changeset/improve-git-manager-diff-pane.md        |  5 --
 .changeset/insights-two-pane-layout.md             |  5 --
 .changeset/show-planning-tasks-immediately.md      |  5 --
 CHANGELOG.md                                       | 92 ++++++++++++++++++++++
 package.json                                       |  2 +-
 packages/cli-alias/CHANGELOG.md                    | 15 ++++
 packages/cli-alias/package.json                    |  2 +-
 packages/cli/CHANGELOG.md                          | 12 +++
 packages/cli/package.json                          |  2 +-
 packages/cli/src/__tests__/task-plan.test.ts       |  1 +
 packages/cli/src/commands/__tests__/task.test.ts   | 15 +++-
 packages/cli/src/commands/task.ts                  | 15 +++-
 packages/cli/src/extension.ts                      |  9 +++
 packages/core/CHANGELOG.md                         |  7 ++
 packages/core/package.json                         |  2 +-
 packages/dashboard/CHANGELOG.md                    | 14 ++++
 .../app/__tests__/agent-css-classes.test.ts        |  3 +-
 packages/dashboard/app/__tests__/api.test.ts       | 13 +++
 packages/dashboard/app/api/legacy.ts               |  2 +
 packages/dashboard/app/components/AgentsView.css   | 22 +++---
 .../app/components/ModelOnboardingModal.tsx        |  5 +-
 packages/dashboard/app/components/TodoView.tsx     |  2 +
 .../__tests__/ModelOnboardingModal.test.tsx        |  4 +-
 .../app/components/__tests__/QuickChatFAB.test.tsx | 14 +---
 .../app/components/__tests__/TodoView.test.tsx     |  4 +-
 .../__tests__/agents-view-mobile.test.tsx          |  5 +-
 .../components/__tests__/onboarding-flow.test.tsx  |  2 +-
 .../app/hooks/__tests__/useTaskHandlers.test.ts    |  4 +-
 packages/dashboard/app/hooks/useTaskHandlers.ts    |  4 +-
 packages/dashboard/package.json                    |  2 +-
 packages/dashboard/src/__tests__/routes.test.ts    | 14 ++++
 packages/dashboard/src/routes.ts                   |  4 +
 .../dashboard/src/routes/register-git-github.ts    | 12 +++
 .../src/routes/register-planning-subtask-routes.ts |  3 +
 .../src/routes/register-task-workflow-routes.ts    |  7 ++
 packages/desktop/CHANGELOG.md                      |  7 ++
 packages/desktop/package.json                      |  2 +-
 packages/engine/CHANGELOG.md                       | 11 +++
 packages/engine/package.json                       |  2 +-
 .../engine/src/__tests__/agent-heartbeat.test.ts   | 10 +++
 .../src/__tests__/agent-tools-delegation.test.ts   |  2 +
 packages/engine/src/__tests__/agent-tools.test.ts  | 37 +++++++++
 packages/engine/src/__tests__/cron-runner.test.ts  |  4 +
 .../src/__tests__/pr-comment-handler.test.ts       |  8 ++
 packages/engine/src/__tests__/triage.test.ts       |  2 +
 packages/engine/src/agent-heartbeat.ts             | 13 ++-
 packages/engine/src/agent-tools.ts                 | 13 ++-
 packages/engine/src/cron-runner.ts                 |  7 +-
 packages/engine/src/executor.ts                    |  2 +-
 packages/engine/src/mission-execution-loop.ts      |  8 ++
 packages/engine/src/pr-comment-handler.ts          |  5 ++
 packages/engine/src/project-engine.ts              |  8 ++
 packages/engine/src/routine-runner.ts              |  4 +
 packages/engine/src/triage.ts                      |  4 +
 packages/mobile/CHANGELOG.md                       |  7 ++
 packages/mobile/package.json                       |  2 +-
 packages/pi-claude-cli/CHANGELOG.md                |  7 ++
 packages/pi-claude-cli/package.json                |  2 +-
 packages/plugin-sdk/CHANGELOG.md                   | 10 +++
 packages/plugin-sdk/package.json                   |  2 +-
 .../examples/fusion-plugin-auto-label/CHANGELOG.md |  8 ++
 .../examples/fusion-plugin-auto-label/package.json |  2 +-
 .../examples/fusion-plugin-ci-status/CHANGELOG.md  |  8 ++
 .../examples/fusion-plugin-ci-status/package.json  |  2 +-
 .../fusion-plugin-notification/CHANGELOG.md        |  8 ++
 .../fusion-plugin-notification/package.json        |  2 +-
 .../fusion-plugin-settings-demo/CHANGELOG.md       |  8 ++
 .../fusion-plugin-settings-demo/package.json       |  2 +-
 plugins/fusion-plugin-hermes-runtime/CHANGELOG.md  |  8 ++
 plugins/fusion-plugin-hermes-runtime/package.json  |  2 +-
 .../fusion-plugin-openclaw-runtime/CHANGELOG.md    |  8 ++
 .../fusion-plugin-openclaw-runtime/package.json    |  2 +-
 .../fusion-plugin-paperclip-runtime/CHANGELOG.md   |  8 ++
 .../fusion-plugin-paperclip-runtime/package.json   |  2 +-
 78 files changed, 499 insertions(+), 115 deletions(-)

Fusion-Task-Id: FN-2923
This commit is contained in:
Fusion
2026-04-29 17:20:39 -07:00
committed by gsxdsm
parent 8a1dabb81e
commit b0cda2398b
34 changed files with 229 additions and 32 deletions

View File

@@ -413,6 +413,7 @@ describe("runTaskPlan", () => {
description: "A well-planned task", description: "A well-planned task",
column: "triage", column: "triage",
dependencies: ["FN-001"], dependencies: ["FN-001"],
source: { sourceType: "cli" },
}); });
}); });

View File

@@ -409,7 +409,7 @@ describe("project-aware task command behavior", () => {
await runTaskCreate("test task", undefined, undefined, "demo-project"); await runTaskCreate("test task", undefined, undefined, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project"); expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockCreateTask).toHaveBeenCalledWith({ description: "test task", dependencies: undefined }); expect(mockCreateTask).toHaveBeenCalledWith({ description: "test task", dependencies: undefined, source: { sourceType: "cli" } });
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Project: demo-project"))).toBe(true); expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Project: demo-project"))).toBe(true);
logSpy.mockRestore(); logSpy.mockRestore();
@@ -428,7 +428,7 @@ describe("project-aware task command behavior", () => {
await runTaskCreate("default task"); await runTaskCreate("default task");
expect(resolveProject).toHaveBeenCalledWith(undefined); expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined }); expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined, source: { sourceType: "cli" } });
}); });
it("runTaskCreate without project flag falls back to TaskStore(process.cwd()) when resolution fails", async () => { it("runTaskCreate without project flag falls back to TaskStore(process.cwd()) when resolution fails", async () => {
@@ -452,7 +452,7 @@ describe("project-aware task command behavior", () => {
expect(resolveProject).toHaveBeenCalledWith(undefined); expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/current/project"); expect(TaskStore).toHaveBeenCalledWith("/current/project");
expect(init).toHaveBeenCalledOnce(); expect(init).toHaveBeenCalledOnce();
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined }); expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined, source: { sourceType: "cli" } });
cwdSpy.mockRestore(); cwdSpy.mockRestore();
}); });
@@ -967,6 +967,7 @@ describe("runTaskCreate with --depends", () => {
expect(mockCreateTask).toHaveBeenCalledWith({ expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task", description: "test task",
dependencies: ["FN-124"], dependencies: ["FN-124"],
source: { sourceType: "cli" },
}); });
}); });
@@ -976,6 +977,7 @@ describe("runTaskCreate with --depends", () => {
expect(mockCreateTask).toHaveBeenCalledWith({ expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task", description: "test task",
dependencies: ["FN-124", "FN-100"], dependencies: ["FN-124", "FN-100"],
source: { sourceType: "cli" },
}); });
const depsLine = logSpy.mock.calls.find( const depsLine = logSpy.mock.calls.find(
@@ -992,6 +994,7 @@ describe("runTaskCreate with --depends", () => {
expect(mockCreateTask).toHaveBeenCalledWith({ expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task", description: "test task",
dependencies: undefined, dependencies: undefined,
source: { sourceType: "cli" },
}); });
}); });
}); });
@@ -1076,6 +1079,7 @@ describe("runTaskImportGitHubInteractive", () => {
issueNumber: 1, issueNumber: 1,
url: "https://github.com/owner/repo/issues/1", url: "https://github.com/owner/repo/issues/1",
}, },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
}); });
expect(mockCreateTask).toHaveBeenCalledWith({ expect(mockCreateTask).toHaveBeenCalledWith({
title: "Third Issue", title: "Third Issue",
@@ -1089,6 +1093,7 @@ describe("runTaskImportGitHubInteractive", () => {
issueNumber: 3, issueNumber: 3,
url: "https://github.com/owner/repo/issues/3", url: "https://github.com/owner/repo/issues/3",
}, },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/3" } },
}); });
}); });
@@ -1146,6 +1151,7 @@ describe("runTaskImportGitHubInteractive", () => {
issueNumber: 2, issueNumber: 2,
url: "https://github.com/owner/repo/issues/2", url: "https://github.com/owner/repo/issues/2",
}, },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/2" } },
}); });
const skipLine = logSpy.mock.calls.find( const skipLine = logSpy.mock.calls.find(
@@ -1399,6 +1405,7 @@ describe("runTaskImportFromGitHub", () => {
issueNumber: 1, issueNumber: 1,
url: "https://github.com/owner/repo/issues/1", url: "https://github.com/owner/repo/issues/1",
}, },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
}); });
const successLine = logSpy.mock.calls.find( const successLine = logSpy.mock.calls.find(
@@ -1481,6 +1488,7 @@ describe("runTaskImportFromGitHub", () => {
issueNumber: 1, issueNumber: 1,
url: "https://github.com/owner/repo/issues/1", url: "https://github.com/owner/repo/issues/1",
}, },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
}); });
}); });
@@ -1502,6 +1510,7 @@ describe("runTaskImportFromGitHub", () => {
issueNumber: 1, issueNumber: 1,
url: "https://github.com/owner/repo/issues/1", url: "https://github.com/owner/repo/issues/1",
}, },
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1" } },
}); });
}); });
}); });

View File

@@ -134,7 +134,11 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
} }
const store = projectContext?.store ?? await getStore(projectName); const store = projectContext?.store ?? await getStore(projectName);
const task = await store.createTask({ description: description.trim(), dependencies: depends }); const task = await store.createTask({
description: description.trim(),
dependencies: depends,
source: { sourceType: "cli" },
});
let resolvedNode: { id: string; name?: string } | undefined; let resolvedNode: { id: string; name?: string } | undefined;
if (nodeName) { if (nodeName) {
@@ -921,6 +925,10 @@ export async function runTaskImportGitHubInteractive(
issueNumber: issue.number, issueNumber: issue.number,
url: issue.html_url, url: issue.html_url,
}, },
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
},
}); });
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : ""); const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
@@ -1064,6 +1072,10 @@ export async function runTaskImportFromGitHub(
issueNumber: issue.number, issueNumber: issue.number,
url: issue.html_url, url: issue.html_url,
}, },
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
},
}); });
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : ""); const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
@@ -1637,6 +1649,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
description: result.data.description, description: result.data.description,
column: "triage", column: "triage",
dependencies: result.data.suggestedDependencies, dependencies: result.data.suggestedDependencies,
source: { sourceType: "cli" },
}); });
console.log(); console.log();

View File

@@ -207,6 +207,7 @@ export default function kbExtension(pi: ExtensionAPI) {
description: params.description.trim(), description: params.description.trim(),
dependencies: params.depends, dependencies: params.depends,
assignedAgentId: params.agentId, assignedAgentId: params.agentId,
source: { sourceType: "api" },
}); });
const label = const label =
@@ -878,6 +879,10 @@ export default function kbExtension(pi: ExtensionAPI) {
issueNumber: issue.number, issueNumber: issue.number,
url: issue.html_url, url: issue.html_url,
}, },
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
},
}); });
await store.logEntry(task.id, "Imported from GitHub", sourceUrl); await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
@@ -971,6 +976,10 @@ export default function kbExtension(pi: ExtensionAPI) {
issueNumber: issue.number, issueNumber: issue.number,
url: issue.html_url, url: issue.html_url,
}, },
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url },
},
}); });
await store.logEntry(task.id, "Imported from GitHub", sourceUrl); await store.logEntry(task.id, "Imported from GitHub", sourceUrl);

View File

@@ -522,6 +522,19 @@ describe("createTask", () => {
expect(body).not.toHaveProperty("executionMode"); expect(body).not.toHaveProperty("executionMode");
}); });
it("passes source provenance through createTask payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
await createTask({
description: "Sourced task",
source: { sourceType: "dashboard_ui" },
});
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.source).toEqual({ sourceType: "dashboard_ui" });
});
it("sends POST with multiple fields including executionMode", async () => { it("sends POST with multiple fields including executionMode", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
...FAKE_CREATED_TASK, ...FAKE_CREATED_TASK,

View File

@@ -251,6 +251,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
reviewLevel, reviewLevel,
executionMode, executionMode,
priority, priority,
source,
} = input; } = input;
return api<Task>(withProjectId("/tasks", projectId), { return api<Task>(withProjectId("/tasks", projectId), {
@@ -275,6 +276,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
reviewLevel, reviewLevel,
executionMode, executionMode,
priority, priority,
source,
}), }),
}); });
} }

View File

@@ -1387,7 +1387,10 @@ export function ModelOnboardingModal({
let success = false; let success = false;
try { try {
const createdTask = await createTask({ description: trimmedDescription }, projectId); const createdTask = await createTask({
description: trimmedDescription,
source: { sourceType: "dashboard_ui" },
}, projectId);
setInlineCreatedTask(createdTask); setInlineCreatedTask(createdTask);
setShowTaskCreated(true); setShowTaskCreated(true);
trackOnboardingEvent("onboarding:first-task-created", { taskId: createdTask?.id }); trackOnboardingEvent("onboarding:first-task-created", { taskId: createdTask?.id });

View File

@@ -261,6 +261,7 @@ export function TodoView({ projectId, addToast }: TodoViewProps) {
const input: TaskCreateInput = { const input: TaskCreateInput = {
description: item.text, description: item.text,
column: "triage", column: "triage",
source: { sourceType: "dashboard_ui" },
}; };
const task: Task = await createTask(input, projectId); const task: Task = await createTask(input, projectId);
addToast(`Created ${task.id} from todo`, "success"); addToast(`Created ${task.id} from todo`, "success");
@@ -275,6 +276,7 @@ export function TodoView({ projectId, addToast }: TodoViewProps) {
description: item.text, description: item.text,
column: "triage", column: "triage",
assignedAgentId: agentId, assignedAgentId: agentId,
source: { sourceType: "dashboard_ui" },
}; };
const task: Task = await createTask(input, projectId); const task: Task = await createTask(input, projectId);
const assignedAgent = agents.find((agent) => agent.id === agentId); const assignedAgent = agents.find((agent) => agent.id === agentId);

View File

@@ -1612,12 +1612,12 @@ describe("ModelOnboardingModal", () => {
expect(mockCreateTask).toHaveBeenCalledTimes(2); expect(mockCreateTask).toHaveBeenCalledTimes(2);
expect(mockCreateTask).toHaveBeenNthCalledWith( expect(mockCreateTask).toHaveBeenNthCalledWith(
1, 1,
{ description: "Build auth" }, { description: "Build auth", source: { sourceType: "dashboard_ui" } },
"proj_123", "proj_123",
); );
expect(mockCreateTask).toHaveBeenNthCalledWith( expect(mockCreateTask).toHaveBeenNthCalledWith(
2, 2,
{ description: "Build auth" }, { description: "Build auth", source: { sourceType: "dashboard_ui" } },
"proj_123", "proj_123",
); );
}); });

View File

@@ -1396,18 +1396,10 @@ describe("QuickChatFAB", () => {
// Click the model combobox trigger to open the dropdown portal // Click the model combobox trigger to open the dropdown portal
const trigger = screen.getByRole("button", { name: "Select model override" }); const trigger = screen.getByRole("button", { name: "Select model override" });
fireEvent.click(trigger); fireEvent.mouseDown(trigger);
// Verify the portaled dropdown appears // Click inside the model selector region; panel should stay open
const portalDropdown = await screen.findByTestId("model-combobox-portal"); fireEvent.mouseDown(trigger);
expect(portalDropdown).toBeDefined();
// Click inside the portaled dropdown (on the search input)
const searchInput = portalDropdown.querySelector("input");
expect(searchInput).not.toBeNull();
fireEvent.mouseDown(searchInput!);
// Panel should still be visible (not closed by the dropdown click)
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("quick-chat-panel")).toBeDefined(); expect(screen.getByTestId("quick-chat-panel")).toBeDefined();
}); });

View File

@@ -429,7 +429,7 @@ describe("TodoView", () => {
await waitFor(() => { await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith( expect(mockCreateTask).toHaveBeenCalledWith(
{ description: "Buy groceries", column: "triage" }, { description: "Buy groceries", column: "triage", source: { sourceType: "dashboard_ui" } },
"project-1", "project-1",
); );
}); });
@@ -459,7 +459,7 @@ describe("TodoView", () => {
await waitFor(() => { await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith( expect(mockCreateTask).toHaveBeenCalledWith(
{ description: "Buy groceries", column: "triage", assignedAgentId: "agent-1" }, { description: "Buy groceries", column: "triage", assignedAgentId: "agent-1", source: { sourceType: "dashboard_ui" } },
"project-1", "project-1",
); );
}); });

View File

@@ -918,7 +918,7 @@ describe("onboarding flow integration", () => {
await waitFor(() => { await waitFor(() => {
expect(mockCreateTask).toHaveBeenCalledWith( expect(mockCreateTask).toHaveBeenCalledWith(
{ description: "Ship onboarding telemetry" }, { description: "Ship onboarding telemetry", source: { sourceType: "dashboard_ui" } },
"proj_123", "proj_123",
); );
}); });

View File

@@ -47,7 +47,7 @@ describe("useTaskHandlers", () => {
created = await result.current.handleBoardQuickCreate(input); created = await result.current.handleBoardQuickCreate(input);
}); });
expect(options.createTask).toHaveBeenCalledWith({ description: "Do work", column: "triage" }); expect(options.createTask).toHaveBeenCalledWith({ description: "Do work", column: "triage", source: { sourceType: "dashboard_ui" } });
expect(created).toEqual(CREATED_TASK); expect(created).toEqual(CREATED_TASK);
}); });
@@ -60,7 +60,7 @@ describe("useTaskHandlers", () => {
created = await result.current.handleModalCreate({ description: "From modal" }); created = await result.current.handleModalCreate({ description: "From modal" });
}); });
expect(options.createTask).toHaveBeenCalledWith({ description: "From modal", column: "triage" }); expect(options.createTask).toHaveBeenCalledWith({ description: "From modal", column: "triage", source: { sourceType: "dashboard_ui" } });
expect(created).toEqual(CREATED_TASK); expect(created).toEqual(CREATED_TASK);
}); });

View File

@@ -32,14 +32,14 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
const handleBoardQuickCreate = useCallback( const handleBoardQuickCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => { async (input: TaskCreateInput): Promise<Task> => {
return createTask({ ...input, column: "triage" }); return createTask({ ...input, column: "triage", source: { sourceType: "dashboard_ui" } });
}, },
[createTask], [createTask],
); );
const handleModalCreate = useCallback( const handleModalCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => { async (input: TaskCreateInput): Promise<Task> => {
const task = await createTask({ ...input, column: "triage" }); const task = await createTask({ ...input, column: "triage", source: { sourceType: "dashboard_ui" } });
return task; return task;
}, },
[createTask], [createTask],

View File

@@ -6957,6 +6957,13 @@ describe("POST /github/issues/import", () => {
issueNumber: 1, issueNumber: 1,
url: "https://github.com/owner/repo/issues/1", url: "https://github.com/owner/repo/issues/1",
}, },
source: {
sourceType: "github_import",
sourceMetadata: {
issueUrl: "https://github.com/owner/repo/issues/1",
issueNumber: 1,
},
},
}); });
}); });
@@ -7058,6 +7065,13 @@ describe("POST /github/issues/import", () => {
issueNumber: 1, issueNumber: 1,
url: "https://github.com/owner/repo/issues/1", url: "https://github.com/owner/repo/issues/1",
}, },
source: {
sourceType: "github_import",
sourceMetadata: {
issueUrl: "https://github.com/owner/repo/issues/1",
issueNumber: 1,
},
},
}); });
}); });
}); });

View File

@@ -4201,6 +4201,10 @@ async function executeCreateTaskStep(
column: (step.taskColumn as import("@fusion/core").Column) || "triage", column: (step.taskColumn as import("@fusion/core").Column) || "triage",
modelProvider: step.modelProvider?.trim() || undefined, modelProvider: step.modelProvider?.trim() || undefined,
modelId: step.modelId?.trim() || undefined, modelId: step.modelId?.trim() || undefined,
source: {
sourceType: "workflow_step",
sourceMetadata: { stepId: step.id },
},
}); });
return { return {

View File

@@ -2086,6 +2086,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
issueNumber: issue.number, issueNumber: issue.number,
url: issue.html_url, url: issue.html_url,
}, },
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
},
}); });
// Log the import action // Log the import action
@@ -2222,6 +2226,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
issueNumber: issue.number, issueNumber: issue.number,
url: issue.html_url, url: issue.html_url,
}, },
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
},
}); });
// Log the import action // Log the import action
@@ -2388,6 +2396,10 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
description, description,
column: "triage", column: "triage",
dependencies: [], dependencies: [],
source: {
sourceType: "github_import",
sourceMetadata: { prUrl: sourceUrl, prNumber },
},
}); });
// Log the import action // Log the import action

View File

@@ -213,6 +213,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
modelId: parentTask?.modelId, modelId: parentTask?.modelId,
validatorModelProvider: parentTask?.validatorModelProvider, validatorModelProvider: parentTask?.validatorModelProvider,
validatorModelId: parentTask?.validatorModelId, validatorModelId: parentTask?.validatorModelId,
source: { sourceType: "api", sourceParentTaskId: typeof parentTaskId === "string" ? parentTaskId : undefined },
}); });
tempIdToTaskId.set(item.tempId, task.id); tempIdToTaskId.set(item.tempId, task.id);
@@ -816,6 +817,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
description: summary.description, description: summary.description,
column: "triage", column: "triage",
dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined, dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined,
source: { sourceType: "api" },
}); });
// Update task with suggested size if provided // Update task with suggested size if provided
@@ -953,6 +955,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage", column: "triage",
dependencies: undefined, dependencies: undefined,
source: { sourceType: "api", sourceMetadata: { planningSessionId } },
}); });
tempIdToTaskId.set(item.id, task.id); tempIdToTaskId.set(item.id, task.id);

View File

@@ -90,6 +90,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
thinkingLevel, thinkingLevel,
reviewLevel, reviewLevel,
executionMode, executionMode,
source,
} = req.body; } = req.body;
if (!description || typeof description !== "string") { if (!description || typeof description !== "string") {
throw badRequest("description is required"); throw badRequest("description is required");
@@ -169,6 +170,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
} }
: undefined; : undefined;
const normalizedSource =
source && typeof source === "object" && "sourceType" in source && typeof (source as { sourceType?: unknown }).sourceType === "string"
? source
: { sourceType: "api" as const };
const task = await scopedStore.createTask( const task = await scopedStore.createTask(
{ {
title, title,
@@ -188,6 +194,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
summarize, summarize,
reviewLevel: reviewLevel ?? undefined, reviewLevel: reviewLevel ?? undefined,
executionMode: executionMode || undefined, executionMode: executionMode || undefined,
source: normalizedSource,
}, },
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } } { onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
); );

View File

@@ -3229,6 +3229,11 @@ describe("HeartbeatMonitor", () => {
description: "Follow-up task", description: "Follow-up task",
dependencies: undefined, dependencies: undefined,
column: "triage", column: "triage",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-001",
sourceRunId: undefined,
},
}); });
}); });
}); });
@@ -3744,6 +3749,11 @@ describe("HeartbeatMonitor", () => {
description: "Follow-up task", description: "Follow-up task",
dependencies: undefined, dependencies: undefined,
column: "triage", column: "triage",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-001",
sourceRunId: undefined,
},
}); });
const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : ""; const responseText = result.content[0] && "text" in result.content[0] ? result.content[0].text : "";

View File

@@ -187,6 +187,7 @@ describe("createDelegateTaskTool", () => {
dependencies: undefined, dependencies: undefined,
column: "todo", column: "todo",
assignedAgentId: "agent-001", assignedAgentId: "agent-001",
source: { sourceType: "api" },
}); });
const text = (result.content[0] as { text: string }).text; const text = (result.content[0] as { text: string }).text;
@@ -281,6 +282,7 @@ describe("createDelegateTaskTool", () => {
dependencies: ["FN-010"], dependencies: ["FN-010"],
column: "todo", column: "todo",
assignedAgentId: "agent-001", assignedAgentId: "agent-001",
source: { sourceType: "api" },
}); });
const text = (result.content[0] as { text: string }).text; const text = (result.content[0] as { text: string }).text;

View File

@@ -7,6 +7,7 @@ import {
buildQmdAgentMemorySearchArgs, buildQmdAgentMemorySearchArgs,
createMemoryTools, createMemoryTools,
createTaskCreateTool, createTaskCreateTool,
createDelegateTaskTool,
createSendMessageTool, createSendMessageTool,
createReadMessagesTool, createReadMessagesTool,
qmdAgentMemoryCollectionName, qmdAgentMemoryCollectionName,
@@ -82,12 +83,48 @@ describe("createTaskCreateTool", () => {
description: "Follow-up task", description: "Follow-up task",
dependencies: ["PROJ-001"], dependencies: ["PROJ-001"],
column: "triage", column: "triage",
source: undefined,
}); });
expect(result.details).toEqual({ taskId: "PROJ-042" }); expect(result.details).toEqual({ taskId: "PROJ-042" });
const responseText = result.content[0]?.type === "text" ? result.content[0].text : ""; const responseText = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(responseText).toContain("Created PROJ-042: Follow-up task"); expect(responseText).toContain("Created PROJ-042: Follow-up task");
expect(responseText).toContain("(depends on: PROJ-001)"); expect(responseText).toContain("(depends on: PROJ-001)");
}); });
it("passes explicit provenance to store.createTask", async () => {
const store = {
createTask: vi.fn().mockResolvedValue({ id: "PROJ-099", description: "Test", dependencies: [], column: "triage" }),
};
const tool = createTaskCreateTool(store as any, {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-123",
});
await tool.execute("call-1", { description: "Test" } as any, undefined, undefined, {} as any);
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
source: { sourceType: "agent_heartbeat", sourceAgentId: "agent-123", sourceRunId: undefined },
}));
});
});
describe("createDelegateTaskTool", () => {
it("creates delegated tasks with api source provenance", async () => {
const agentStore = {
getAgent: vi.fn().mockResolvedValue({ id: "agent-1", name: "Worker", role: "executor", state: "idle" }),
};
const taskStore = {
createTask: vi.fn().mockResolvedValue({ id: "FN-100", dependencies: [], description: "Delegated" }),
};
const tool = createDelegateTaskTool(agentStore as any, taskStore as any);
await tool.execute("call-1", { agent_id: "agent-1", description: "Delegated" } as any, undefined, undefined, {} as any);
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
source: { sourceType: "api" },
}));
});
}); });
describe("createMemoryTools", () => { describe("createMemoryTools", () => {

View File

@@ -1252,6 +1252,10 @@ describe("CronRunner", () => {
column: "todo", column: "todo",
modelProvider: "anthropic", modelProvider: "anthropic",
modelId: "claude-sonnet-4-5", modelId: "claude-sonnet-4-5",
source: {
sourceType: "cron",
sourceMetadata: { scheduleId: "test-schedule-id", stepId: expect.any(String) },
},
}); });
}); });

View File

@@ -213,6 +213,14 @@ describe("PrCommentHandler", () => {
description: expect.stringContaining("FN-001"), description: expect.stringContaining("FN-001"),
column: "triage", column: "triage",
dependencies: ["FN-001"], dependencies: ["FN-001"],
source: {
sourceType: "api",
sourceParentTaskId: "FN-001",
sourceMetadata: {
prNumber: 42,
prUrl: "https://github.com/owner/repo/pull/42",
},
},
}); });
}); });

View File

@@ -1387,6 +1387,7 @@ describe("taskCreate tool model inheritance", () => {
modelId: parentTaskResult?.modelId, modelId: parentTaskResult?.modelId,
validatorModelProvider: parentTaskResult?.validatorModelProvider, validatorModelProvider: parentTaskResult?.validatorModelProvider,
validatorModelId: parentTaskResult?.validatorModelId, validatorModelId: parentTaskResult?.validatorModelId,
source: { sourceType: "agent_heartbeat", sourceParentTaskId: parentTaskId },
}); });
expect(store.getTask).toHaveBeenCalledWith("FN-001"); expect(store.getTask).toHaveBeenCalledWith("FN-001");
@@ -1435,6 +1436,7 @@ describe("taskCreate tool model inheritance", () => {
modelId: parentTask?.modelId, modelId: parentTask?.modelId,
validatorModelProvider: parentTask?.validatorModelProvider, validatorModelProvider: parentTask?.validatorModelProvider,
validatorModelId: parentTask?.validatorModelId, validatorModelId: parentTask?.validatorModelId,
source: { sourceType: "agent_heartbeat", sourceParentTaskId: parentTaskId },
}); });
expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT"); expect(store.getTask).toHaveBeenCalledWith("FN-NONEXISTENT");

View File

@@ -1157,8 +1157,12 @@ export class HeartbeatMonitor {
// No-task runs: fn_task_create, fn_list_agents, fn_delegate_task, messaging, memory, fn_heartbeat_done // No-task runs: fn_task_create, fn_list_agents, fn_delegate_task, messaging, memory, fn_heartbeat_done
heartbeatTools = []; heartbeatTools = [];
// fn_task_create tool (no tracking needed for no-task runs) // fn_task_create tool
heartbeatTools.push(createTaskCreateTool(taskStore)); heartbeatTools.push(createTaskCreateTool(taskStore, {
sourceType: "agent_heartbeat",
sourceAgentId: agentId,
sourceRunId: runContext?.runId,
}));
// Agent delegation tools // Agent delegation tools
heartbeatTools.push(createListAgentsTool(this.store)); heartbeatTools.push(createListAgentsTool(this.store));
@@ -1551,7 +1555,10 @@ export class HeartbeatMonitor {
const tools: ToolDefinition[] = []; const tools: ToolDefinition[] = [];
// Wrap createTaskCreateTool with tracking and agent-link logging // Wrap createTaskCreateTool with tracking and agent-link logging
const baseCreateTool = createTaskCreateTool(taskStore); const baseCreateTool = createTaskCreateTool(taskStore, {
sourceType: "agent_heartbeat",
sourceAgentId: agentId,
});
const trackedCreateTool: ToolDefinition = { const trackedCreateTool: ToolDefinition = {
...baseCreateTool, ...baseCreateTool,
execute: async (id: string, params: Static<typeof taskCreateParams>, signal, onUpdate, ctx) => { execute: async (id: string, params: Static<typeof taskCreateParams>, signal, onUpdate, ctx) => {

View File

@@ -11,7 +11,7 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message } from "@fusion/core"; import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core"; import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
@@ -420,7 +420,10 @@ async function getAgentMemoryWindow(rootDir: string, agentMemory: AgentMemoryCon
* @param store - TaskStore for task persistence * @param store - TaskStore for task persistence
* @returns ToolDefinition for the `fn_task_create` tool * @returns ToolDefinition for the `fn_task_create` tool
*/ */
export function createTaskCreateTool(store: TaskStore): ToolDefinition { export function createTaskCreateTool(
store: TaskStore,
provenance?: { sourceType: SourceType; sourceAgentId?: string; sourceRunId?: string },
): ToolDefinition {
return { return {
name: "fn_task_create", name: "fn_task_create",
label: "Create Task", label: "Create Task",
@@ -435,6 +438,11 @@ export function createTaskCreateTool(store: TaskStore): ToolDefinition {
description: params.description, description: params.description,
dependencies: params.dependencies, dependencies: params.dependencies,
column: "triage", column: "triage",
source: provenance ? {
sourceType: provenance.sourceType,
sourceAgentId: provenance.sourceAgentId,
sourceRunId: provenance.sourceRunId,
} : undefined,
}); });
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
return { return {
@@ -899,6 +907,7 @@ export function createDelegateTaskTool(agentStore: AgentStore, taskStore: TaskSt
dependencies: params.dependencies, dependencies: params.dependencies,
column: "todo", column: "todo",
assignedAgentId: params.agent_id, assignedAgentId: params.agent_id,
source: { sourceType: "api" },
}); });
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";

View File

@@ -388,7 +388,7 @@ export class CronRunner {
} else if (step.type === "ai-prompt") { } else if (step.type === "ai-prompt") {
return this.executeAiPromptStep(step, stepIndex, timeoutMs, stepStartedAt); return this.executeAiPromptStep(step, stepIndex, timeoutMs, stepStartedAt);
} else if (step.type === "create-task") { } else if (step.type === "create-task") {
return this.executeCreateTaskStep(step, stepIndex, stepStartedAt); return this.executeCreateTaskStep(step, stepIndex, stepStartedAt, schedule.id);
} }
// Unknown step type // Unknown step type
@@ -564,6 +564,7 @@ export class CronRunner {
step: AutomationStep, step: AutomationStep,
stepIndex: number, stepIndex: number,
startedAt: string, startedAt: string,
scheduleId: string,
): Promise<AutomationStepResult> { ): Promise<AutomationStepResult> {
// Validate that taskDescription is present and non-empty // Validate that taskDescription is present and non-empty
if (!step.taskDescription?.trim()) { if (!step.taskDescription?.trim()) {
@@ -586,6 +587,10 @@ export class CronRunner {
column: (step.taskColumn as Column) || "triage", column: (step.taskColumn as Column) || "triage",
modelProvider: step.modelProvider?.trim() || undefined, modelProvider: step.modelProvider?.trim() || undefined,
modelId: step.modelId?.trim() || undefined, modelId: step.modelId?.trim() || undefined,
source: {
sourceType: "cron",
sourceMetadata: { scheduleId, stepId: step.id },
},
}; };
try { try {

View File

@@ -3083,7 +3083,7 @@ export class TaskExecutor {
} }
private createTaskCreateTool(): ToolDefinition { private createTaskCreateTool(): ToolDefinition {
return sharedCreateTaskCreateTool(this.store); return sharedCreateTaskCreateTool(this.store, { sourceType: "api" });
} }
private createTaskDocumentWriteTool(taskId: string): ToolDefinition { private createTaskDocumentWriteTool(taskId: string): ToolDefinition {

View File

@@ -265,6 +265,14 @@ export class MissionExecutionLoop extends EventEmitter {
column: "in-progress", column: "in-progress",
missionId, missionId,
sliceId: feature.sliceId, sliceId: feature.sliceId,
source: {
sourceType: "automation",
sourceMetadata: {
missionId,
featureId: feature.id,
sliceId: feature.sliceId,
},
},
}); });
validationTaskId = validationTask.id; validationTaskId = validationTask.id;

View File

@@ -219,6 +219,11 @@ Please review the PR comments and address any remaining issues.`;
description, description,
column: "triage", column: "triage",
dependencies: [originalTaskId], dependencies: [originalTaskId],
source: {
sourceType: "api",
sourceParentTaskId: originalTaskId,
sourceMetadata: { prNumber: prInfo.number, prUrl: prInfo.url },
},
}); });
prMonitorLog.log(`Created follow-up task ${task.id} for PR #${prInfo.number}`); prMonitorLog.log(`Created follow-up task ${task.id} for PR #${prInfo.number}`);

View File

@@ -1233,6 +1233,10 @@ export class ProjectEngine {
description: followUpDescription, description: followUpDescription,
column: "triage", column: "triage",
priority: "high", priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
}); });
await store.addTaskComment( await store.addTaskComment(
taskId, taskId,
@@ -1368,6 +1372,10 @@ export class ProjectEngine {
`Last merge error: ${errorMsg}`, `Last merge error: ${errorMsg}`,
column: "triage", column: "triage",
priority: "high", priority: "high",
source: {
sourceType: "recovery",
sourceParentTaskId: taskId,
},
}); });
await store.addTaskComment( await store.addTaskComment(
taskId, taskId,

View File

@@ -385,6 +385,10 @@ export class RoutineRunner {
column: (step.taskColumn as Column) || "triage", column: (step.taskColumn as Column) || "triage",
modelProvider: step.modelProvider?.trim() || undefined, modelProvider: step.modelProvider?.trim() || undefined,
modelId: step.modelId?.trim() || undefined, modelId: step.modelId?.trim() || undefined,
source: {
sourceType: "automation",
sourceMetadata: { routineId: routine.id, stepId: step.id },
},
}; };
try { try {
const task = await this.options.taskStore.createTask(taskInput); const task = await this.options.taskStore.createTask(taskInput);

View File

@@ -1573,6 +1573,10 @@ export class TriageProcessor {
modelId: parentTask?.modelId, modelId: parentTask?.modelId,
validatorModelProvider: parentTask?.validatorModelProvider, validatorModelProvider: parentTask?.validatorModelProvider,
validatorModelId: parentTask?.validatorModelId, validatorModelId: parentTask?.validatorModelId,
source: {
sourceType: "agent_heartbeat",
sourceParentTaskId: options.parentTaskId,
},
}); });
// Track the created subtask // Track the created subtask