FN-7611: respect workflow intake column on task creation

Task creation surfaces stopped hardcoding column:"triage", so new tasks now land in the selected-or-default workflow's resolved intake column instead of always jumping to Planning/triage.

- Removed hardcoded column:"triage" override in engine's createTaskCreateTool (fn_task_create), letting TaskStore.createTask resolve the landing column from the workflow's intake-trait column.
- Removed the equivalent hardcoded override in the pi extension's fn_task_create, and updated its response text to echo the actual landing column instead of a fixed "Column: triage" string.
- Fixed signal-route, GitHub-import, and planning-subtask-route task creation to stop forcing column when no workflowId is given (or, for planning subtask routes, even when one is provided).
- Custom workflows with a non-triage intake column (e.g. Inbox) now correctly capture new cards inert until released, while the default builtin:coding workflow still resolves to "triage" byte-identically.
- Added regression coverage (agent-tools-intake-column.test.ts, extension-workflow-tools.test.ts) and a patch changeset documenting the fix.

Files changed:
 .changeset/fn-7611-intake-column.md                |   7 ++
 .../src/__tests__/extension-workflow-tools.test.ts |  70 +++++++++++
 packages/cli/src/extension.ts                      |  10 +-
 .../src/__tests__/register-signal-routes.test.ts   |   8 +-
 .../dashboard/src/__tests__/routes-github.test.ts  |   2 -
 .../dashboard/src/routes/register-git-github.ts    |  16 ++-
 .../src/routes/register-planning-subtask-routes.ts |  24 +++-
 .../dashboard/src/routes/register-signal-routes.ts |   8 +-
 .../__tests__/agent-tools-intake-column.test.ts    | 138 +++++++++++++++++++++
 packages/engine/src/__tests__/agent-tools.test.ts  |   1 -
 packages/engine/src/agent-tools.ts                 |  21 +++-
 11 files changed, 288 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7611

Fusion-Task-Lineage: daf7f755-b1c7-4859-b74f-f15593d5e79e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 21:35:51 -07:00
parent e44458119c
commit 203f879c8f
11 changed files with 288 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: New tasks now land in the selected workflow's intake column instead of always jumping to Planning/triage.
category: fix
dev: Removed hardcoded `column: "triage"` overrides in `fn_task_create` (engine `createTaskCreateTool` and pi extension) and in signal/GitHub-import/planning create surfaces that had no `workflowId` or (for planning subtask routes) accepted one but still forced `column`. `TaskStore.createTask` already resolves `input.column || resolvedEntryColumn || "triage"`; callers no longer defeat that resolution. A custom workflow's non-triage `intake`-trait column (e.g. `Inbox`) now correctly captures new cards inert until released, while the default builtin:coding workflow still lands cards in `triage` byte-identically. The pi-extension `fn_task_create` response text now echoes the actual landing column instead of a fixed `"Column: triage"` string.

View File

@@ -241,4 +241,74 @@ describe("pi extension workflow authoring tools", () => {
expect(ambient.isError).not.toBe(true);
expect(ambient.details.taskId).toBe(task.details.taskId);
});
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611: fn_task_create must land a new card in the selected workflow's resolved
intake column (not a hardcoded "triage"), and its response text must echo that
ACTUAL landing column instead of a fixed "Column: triage" string.
*/
it("lands a task in a custom workflow's intake column and echoes it in the response text", async () => {
const inboxIr: WorkflowIr = {
version: "v2",
name: "Inbox-intake workflow",
columns: [
{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] },
{ id: "todo", name: "Todo", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "inbox" },
{
id: "plan",
kind: "prompt",
column: "todo",
config: { name: "Plan", prompt: "Plan the work", autoApprove: true },
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "plan", condition: "success" },
{ from: "plan", to: "end", condition: "success" },
],
} as WorkflowIr;
const createWorkflow = await api.tools.get("fn_workflow_create")!.execute(
"create-inbox-workflow",
{ name: "Inbox-intake workflow", ir: inboxIr },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(createWorkflow.isError).not.toBe(true);
const workflowId = createWorkflow.details.workflowId;
const createTask = api.tools.get("fn_task_create")!;
const result = await createTask.execute(
"create-inbox-task",
{ description: "Needs manual release", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.column).toBe("inbox");
expect(result.content[0].text).toContain("Column: inbox");
expect(result.content[0].text).not.toContain("Column: triage");
});
it("still reports Column: triage for the default builtin:coding workflow (byte-identical regression guard)", async () => {
const createTask = api.tools.get("fn_task_create")!;
const result = await createTask.execute(
"create-default-task",
{ description: "Default workflow task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.column).toBe("triage");
expect(result.content[0].text).toContain("Column: triage");
});
});

View File

@@ -828,13 +828,21 @@ export default function kbExtension(pi: ExtensionAPI) {
? task.description.slice(0, 80) + "…"
: task.description;
/*
FNXC:Workflows 2026-07-05-00:00:
The response text must reflect the ACTUAL resolved landing column (task.column),
not a hardcoded "triage" string. store.createTask already resolves intake correctly
(this call never overrides `column`), so a custom workflow's non-triage intake
column (e.g. "Inbox") must be echoed back to the caller instead of a fixed value
that would misreport where the card actually landed.
*/
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}${workflowId ? ` (workflow: ${workflowId})` : ""}\n` +
`Column: triage\n` +
`Column: ${task.column}\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") +

View File

@@ -37,7 +37,9 @@ function makeStore(db?: Database) {
id: `FN-${++counter}`,
title: input.title,
description: input.description,
column: input.column,
// FNXC:Workflows 2026-07-05-00:00: FN-7611 — mirror the real store's intake-column
// resolution (input.column || resolvedEntryColumn || "triage") for the default workflow.
column: input.column ?? "triage",
source: input.source,
} as unknown as Task;
tasks.push(task);
@@ -913,7 +915,7 @@ describe("helpers", () => {
})).toEqual(["webhook", "pagerduty", "gitlab"]);
});
it("signalToTaskInput maps to a triage task with provenance metadata", () => {
it("signalToTaskInput omits column so the store resolves the default-workflow intake (triage)", () => {
const input = signalToTaskInput({
source: "webhook",
externalId: "e",
@@ -921,7 +923,7 @@ describe("helpers", () => {
title: "t",
severity: "critical",
});
expect(input.column).toBe("triage");
expect(input.column).toBeUndefined();
expect(input.priority).toBe("high");
expect(input.source?.sourceType).toBe("api");
});

View File

@@ -608,7 +608,6 @@ describe("POST /github/issues/import", () => {
expect(store.createTask).toHaveBeenCalledWith({
title: "Test Issue",
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
@@ -790,7 +789,6 @@ describe("POST /github/issues/import", () => {
expect(store.createTask).toHaveBeenCalledWith({
title: "A".repeat(200),
description: expect.stringContaining("Source:"),
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",

View File

@@ -4017,10 +4017,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const importedIssueGithubTracking = await resolveImportedIssueGithubTracking(scopedStore);
const source = buildGitHubIssueSource(owner, repo, issue);
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611: do not hardcode column here. This import path has no workflowId, so the
store resolves the landing column from the PROJECT-DEFAULT workflow's intake trait
(byte-identical "triage" for builtin:coding; a custom default workflow's own intake
column otherwise).
*/
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: source.sourceIssue,
source: {
@@ -4154,10 +4160,12 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
try {
const source = buildGitHubIssueSource(owner, repo, issue);
// FNXC:Workflows 2026-07-05-00:00: FN-7611 — no workflowId here; let the store
// resolve the project-default workflow's intake column (byte-identical "triage"
// for builtin:coding).
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: source.sourceIssue,
source: {
@@ -4481,10 +4489,12 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const body = pr.body?.trim() || "(no description)";
const description = `Review and address any issues in this pull request.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch} → ${pr.baseBranch}\n\n${body}`;
// FNXC:Workflows 2026-07-05-00:00: FN-7611 — no workflowId here; let the store
// resolve the project-default workflow's intake column (byte-identical "triage"
// for builtin:coding).
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
source: {
sourceType: "github_import",

View File

@@ -285,10 +285,16 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
taskSegment: item.title || item.tempId,
});
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611: do not hardcode column here. This route accepts an explicit workflowId
(below), so a hardcoded "triage" would defeat that custom workflow's own intake
column resolution. Omitting `column` lets the store resolve intake for the
selected-or-default workflow (byte-identical "triage" for builtin:coding).
*/
const task = await scopedStore.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage",
dependencies: undefined,
// Inherit parent's model settings if available
modelProvider: parentTask?.modelProvider,
@@ -1130,11 +1136,17 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
resolveBranchSelection(branchSelection, branch, baseBranch);
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611: do not hardcode column here. This route accepts an explicit workflowId
(below), so a hardcoded "triage" would defeat that custom workflow's own intake
column resolution. Omitting `column` lets the store resolve intake for the
selected-or-default workflow (byte-identical "triage" for builtin:coding).
*/
// Create the task
const task = await scopedStore.createTask({
title: summary.title,
description: summary.description,
column: "triage",
dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined,
priority: isTaskPriority(summary.priority) ? summary.priority : DEFAULT_TASK_PRIORITY,
source: { sourceType: "api" },
@@ -1367,10 +1379,16 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
taskSegment: item.title || item.id,
});
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611: do not hardcode column here. This route accepts an explicit workflowId
(below), so a hardcoded "triage" would defeat that custom workflow's own intake
column resolution. Omitting `column` lets the store resolve intake for the
selected-or-default workflow (byte-identical "triage" for builtin:coding).
*/
const task = await scopedStore.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage",
dependencies: undefined,
priority: isTaskPriority(item.priority) ? item.priority : DEFAULT_TASK_PRIORITY,
source: { sourceType: "api", sourceMetadata: { planningSessionId } },

View File

@@ -125,7 +125,13 @@ export function signalToTaskInput(signal: Signal): Parameters<TaskStore["createT
return {
title: signal.title,
description,
column: "triage",
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611: do not hardcode column here. This path has no workflowId, so
TaskStore.createTask resolves the landing column from the PROJECT-DEFAULT
workflow's intake trait (byte-identical "triage" for builtin:coding; a custom
default workflow's own intake column, e.g. Inbox, otherwise).
*/
priority: signal.severity === "critical" ? "high" : undefined,
source: {
// Reuse the existing `api` source type — signals arrive over the API

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore, type WorkflowIr } from "@fusion/core";
import { createTaskCreateTool } from "../agent-tools.js";
/*
FNXC:Workflows 2026-07-05-00:00:
FN-7611 regression suite: fn_task_create (createTaskCreateTool) must NOT override the
store's intake-column resolution with a hardcoded "triage". A custom workflow with a
non-triage `intake`-trait column (e.g. "Inbox") must capture new cards there, inert
(bootstrap-stub PROMPT.md, no Planner spec generation), while the default builtin:coding
workflow keeps landing cards in "triage" byte-identically.
*/
describe("createTaskCreateTool intake-column wiring", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "kb-engine-agent-tools-intake-"));
globalDir = mkdtempSync(join(tmpdir(), "kb-engine-agent-tools-intake-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
function inboxWorkflowIr(name: string): WorkflowIr {
return {
version: "v2",
name,
columns: [
{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] },
{ id: "todo", name: "Todo", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "inbox" },
{
id: "plan",
kind: "prompt",
column: "todo",
config: { name: "Plan", prompt: "Plan the work", autoApprove: true },
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "plan", condition: "success" },
{ from: "plan", to: "end", condition: "success" },
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
it("lands a task in the custom workflow's Inbox intake column when selected explicitly via fn_task_create", async () => {
const created = await store.createWorkflowDefinition({
name: "Inbox-intake workflow",
ir: inboxWorkflowIr("Inbox-intake workflow"),
});
const tool = createTaskCreateTool(store);
const result = await tool.execute(
"call-1",
{ description: "Needs manual release", workflow_id: created.id } as never,
undefined,
undefined,
{} as never,
);
expect((result as { isError?: boolean }).isError).toBeFalsy();
const task = await store.getTask((result.details as { taskId: string }).taskId);
expect(task.column).toBe("inbox");
});
it("keeps a task with no workflow_id landing in triage (byte-identical default)", async () => {
const tool = createTaskCreateTool(store);
const result = await tool.execute(
"call-2",
{ description: "Default workflow task" } as never,
undefined,
undefined,
{} as never,
);
expect((result as { isError?: boolean }).isError).toBeFalsy();
const task = await store.getTask((result.details as { taskId: string }).taskId);
expect(task.column).toBe("triage");
});
it("lands a task explicitly selecting builtin:coding in triage even when the project default is the custom intake workflow", async () => {
const created = await store.createWorkflowDefinition({
name: "Inbox-intake workflow 2",
ir: inboxWorkflowIr("Inbox-intake workflow 2"),
});
await store.setDefaultWorkflowId(created.id);
const tool = createTaskCreateTool(store);
const result = await tool.execute(
"call-3",
{ description: "Explicit default coding workflow task", workflow_id: "builtin:coding" } as never,
undefined,
undefined,
{} as never,
);
expect((result as { isError?: boolean }).isError).toBeFalsy();
const task = await store.getTask((result.details as { taskId: string }).taskId);
expect(task.column).toBe("triage");
});
it("writes a bootstrap PROMPT.md (unplanned) for the inbox-landed task, matching the store's intake gate", async () => {
const created = await store.createWorkflowDefinition({
name: "Inbox-intake workflow 3",
ir: inboxWorkflowIr("Inbox-intake workflow 3"),
});
const tool = createTaskCreateTool(store);
const result = await tool.execute(
"call-4",
{ description: "Inbox bootstrap prompt task", workflow_id: created.id } as never,
undefined,
undefined,
{} as never,
);
expect((result as { isError?: boolean }).isError).toBeFalsy();
const taskId = (result.details as { taskId: string }).taskId;
const prompt = await readFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), "utf-8");
expect(prompt).toBe(`# ${taskId}\n\nInbox bootstrap prompt task\n`);
});
});

View File

@@ -142,7 +142,6 @@ describe("createTaskCreateTool", () => {
expect(store.createTask).toHaveBeenCalledWith({
description: "Follow-up task",
dependencies: ["PROJ-001"],
column: "triage",
priority: undefined,
summarize: true,
source: undefined,

View File

@@ -943,7 +943,8 @@ export async function createAgentTask(
}
/**
* Create a `fn_task_create` tool that creates a new task in triage.
* Create a `fn_task_create` tool that creates a new task in the selected-or-default
* workflow's resolved intake column.
*
* @param store - TaskStore for task persistence
* @returns ToolDefinition for the `fn_task_create` tool
@@ -958,7 +959,10 @@ export function createTaskCreateTool(
label: "Create Task",
description:
"Create a new task for out-of-scope work discovered during execution. " +
"The task goes into triage where it will be specified by the AI. " +
"The task enters the selected-or-default workflow's intake/planning column " +
"where it will be specified by the AI (a custom workflow with a non-triage " +
"intake column, e.g. Inbox, lands the card there instead and it stays inert " +
"until released). " +
"Before creating, scan existing open tasks for similar work — if an open task " +
"already covers this, do not create a duplicate. " +
"Optionally set dependencies (e.g., the new task depends on the current one, " +
@@ -987,10 +991,21 @@ export function createTaskCreateTool(
}
}
const workflowId = params.workflow_id?.trim() || undefined;
/*
FNXC:Workflows 2026-07-05-00:00:
fn_task_create must NOT hardcode column:"triage" here. TaskStore.createTask already
resolves the landing column from the selected-or-default workflow's intake-trait
column (input.column || resolvedEntryColumn || "triage" in _createTaskInternal); a
hardcoded override here defeated that resolution and made a non-triage intake column
(e.g. a custom workflow's "Inbox" hold column) dead configuration, since the card
always jumped straight into triage and started the Planner seam immediately.
Omitting `column` lets a custom workflow's Inbox-style intake column capture new
cards inert (no bootstrap spec generation) while the default builtin:coding workflow
still resolves to "triage" (byte-identical prior behavior).
*/
const { task, wasDuplicate } = await createAgentTask(store, {
description: params.description,
dependencies: params.dependencies,
column: "triage",
priority: params.priority,
...(workflowId ? { workflowId } : {}),
source: provenance ? {