diff --git a/.changeset/fn-7611-intake-column.md b/.changeset/fn-7611-intake-column.md new file mode 100644 index 0000000000..5e2ebcfdf6 --- /dev/null +++ b/.changeset/fn-7611-intake-column.md @@ -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. diff --git a/packages/cli/src/__tests__/extension-workflow-tools.test.ts b/packages/cli/src/__tests__/extension-workflow-tools.test.ts index 545ba7dbca..5f4c49ea4b 100644 --- a/packages/cli/src/__tests__/extension-workflow-tools.test.ts +++ b/packages/cli/src/__tests__/extension-workflow-tools.test.ts @@ -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"); + }); }); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 00c67d39c8..11d0ad7c6e 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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` : "") + diff --git a/packages/dashboard/src/__tests__/register-signal-routes.test.ts b/packages/dashboard/src/__tests__/register-signal-routes.test.ts index a562960355..649542a950 100644 --- a/packages/dashboard/src/__tests__/register-signal-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-signal-routes.test.ts @@ -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"); }); diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index a01ae12258..f20b802264 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -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", diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 55b765325b..8a9d5ef82b 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -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", diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 7f7c86d114..b7e837447f 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -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 } }, diff --git a/packages/dashboard/src/routes/register-signal-routes.ts b/packages/dashboard/src/routes/register-signal-routes.ts index 1ab8932b92..4470e0053f 100644 --- a/packages/dashboard/src/routes/register-signal-routes.ts +++ b/packages/dashboard/src/routes/register-signal-routes.ts @@ -125,7 +125,13 @@ export function signalToTaskInput(signal: Signal): Parameters { + 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`); + }); +}); diff --git a/packages/engine/src/__tests__/agent-tools.test.ts b/packages/engine/src/__tests__/agent-tools.test.ts index b964ba6167..0e3c5fe4b7 100644 --- a/packages/engine/src/__tests__/agent-tools.test.ts +++ b/packages/engine/src/__tests__/agent-tools.test.ts @@ -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, diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 6f1b9ca20d..4c58a7332c 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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 ? {