FN-6168: expose workflow routing in triage

Add workflow discovery, selection, and investigation-task routing guidance to triage.

- document workflow routing guidance in standard and fast triage prompts
- add workflow list/select tools to triage sessions for task specification
- pass workflow_id and noCommitsExpected through fn_task_create for child tasks
- cover prompt/tool availability and child-task routing in triage tests
- add a changeset and workflow docs for triage workflow selection behavior

Files changed:
 .changeset/fn-6168-triage-workflow-routing.md |  5 ++
 docs/workflow-steps.md                        |  2 +
 packages/engine/src/__tests__/triage.test.ts  | 93 +++++++++++++++++++++++++++
 packages/engine/src/triage.ts                 | 27 ++++++++
 4 files changed, 127 insertions(+)

Fusion-Task-Id: FN-6168

Fusion-Task-Lineage: acfcebd5-bb9c-42ba-90a0-e9bcb90216b3
This commit is contained in:
gsxdsm
2026-06-09 22:34:34 -07:00
parent 4d7c51d526
commit 1b7e52ea99
4 changed files with 127 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Expose workflow discovery and selection during triage planning, including workflow routing metadata for child task creation.

View File

@@ -40,6 +40,8 @@ The default built-in catalog entry `builtin:coding` is backed by the canonical `
`builtin:stepwise-coding` is a separate graph variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure. `builtin:stepwise-coding` is a separate graph variant backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while modeling per-step parse/execute/review/rework as authored graph structure.
During triage/planning sessions, agents can call `fn_workflow_list` to discover available built-in and custom workflows and read their descriptions before routing work. They can call `fn_workflow_select` to select a workflow for the task being specified, or pass `workflow_id` when creating child tasks with `fn_task_create`; decision-only or investigation tasks can also set `noCommitsExpected` / `**No commits expected:** true` when no code changes are expected.
#### Runtime invariant criterion #### Runtime invariant criterion
Workflow-driven coding runs must preserve observable task transitions and reliability invariants: file-scope guards including `FileScopeViolationError`, squash/merge contract, recovery expectations, `autoMerge:false` terminal-until-merged, and `moveTask(in-progress→todo)` hard-cancel semantics. Workflow-driven coding runs must preserve observable task transitions and reliability invariants: file-scope guards including `FileScopeViolationError`, squash/merge contract, recovery expectations, `autoMerge:false` terminal-until-merged, and `moveTask(in-progress→todo)` hard-cancel semantics.

View File

@@ -737,6 +737,18 @@ describe("fast-mode triage", () => {
expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("Frontend UX Criteria"); expect(FAST_TRIAGE_SYSTEM_PROMPT).not.toContain("Frontend UX Criteria");
}); });
it("documents workflow routing in standard and fast prompts", () => {
for (const prompt of [TRIAGE_SYSTEM_PROMPT, FAST_TRIAGE_SYSTEM_PROMPT]) {
expect(prompt).toContain("## Workflow Routing");
expect(prompt).toContain("fn_workflow_list");
expect(prompt).toContain("fn_workflow_select");
expect(prompt).toContain("workflow_id");
expect(prompt).toContain("**No commits expected:** true");
expect(prompt).toContain("builtin:quick-fix");
expect(prompt).toContain("builtin:coding");
}
});
it("includes task-artifact location guidance for forensic/reconciliation tasks", () => { it("includes task-artifact location guidance for forensic/reconciliation tasks", () => {
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("Task Artifact Location"); expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("Task Artifact Location");
expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/"); expect(FAST_TRIAGE_SYSTEM_PROMPT).toContain("<rootDir>/.fusion/tasks/{TARGET_ID}/");
@@ -1206,6 +1218,32 @@ describe("TriageProcessor", () => {
expect(processor).toBeInstanceOf(TriageProcessor); expect(processor).toBeInstanceOf(TriageProcessor);
}); });
it("includes workflow discovery and selection tools in the full triage toolset", async () => {
const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" });
const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(detailedTask);
let capturedTools: any[] = [];
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedTools = opts.customTools;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
await processor.specifyTask(task);
const toolNames = capturedTools.map((tool) => tool.name);
expect(toolNames).toContain("fn_workflow_list");
expect(toolNames).toContain("fn_workflow_select");
});
it("can be started and stopped", () => { it("can be started and stopped", () => {
processor.start(); processor.start();
processor.stop(); processor.stop();
@@ -2051,6 +2089,61 @@ describe("taskCreate tool model inheritance", () => {
})); }));
}); });
it("fn_task_create passes workflow_id and noCommitsExpected through to child tasks", async () => {
const parentTask: Task = {
id: "FN-410",
description: "Parent task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const createdSubtask: Task = {
...parentTask,
id: "FN-411",
description: "Decision child task",
workflowId: "builtin:quick-fix",
noCommitsExpected: true,
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(parentTask),
createTask: vi.fn().mockResolvedValue(createdSubtask),
});
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-410",
allowTaskCreate: true,
createdSubtasksRef,
});
const taskCreateTool = tools.find((t: any) => t.name === "fn_task_create");
const result = await taskCreateTool.execute("call-1", {
description: "Investigate and report the routing decision",
workflow_id: "builtin:quick-fix",
noCommitsExpected: true,
});
expect(result.content[0].text).toContain("Created child task FN-411");
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
description: "Investigate and report the routing decision",
workflowId: "builtin:quick-fix",
noCommitsExpected: true,
}), expect.objectContaining({
settings: expect.objectContaining({
maxConcurrent: 2,
maxWorktrees: 4,
}),
}));
expect(createdSubtasksRef.current).toContain("FN-411");
});
it("fn_task_create rejects a dependency on the parent task being split", async () => { it("fn_task_create rejects a dependency on the parent task being split", async () => {
// Regression: triage used to accept any id in `dependencies`. If the AI // Regression: triage used to accept any id in `dependencies`. If the AI
// named the parent, the parent got deleted after the split and the child // named the parent, the parent got deleted after the split and the child

View File

@@ -75,6 +75,8 @@ import {
createWebFetchTool, createWebFetchTool,
createTaskDocumentReadTool, createTaskDocumentReadTool,
createTaskDocumentWriteTool, createTaskDocumentWriteTool,
createWorkflowListTool,
createWorkflowSelectTool,
} from "./agent-tools.js"; } from "./agent-tools.js";
import { import {
getResearchGuidanceForSurface, getResearchGuidanceForSurface,
@@ -343,6 +345,14 @@ commands, use those EXACT commands in the testing/verification steps and anywher
the spec references running tests or builds. Do NOT guess or infer commands from the spec references running tests or builds. Do NOT guess or infer commands from
package.json when explicit commands are provided. package.json when explicit commands are provided.
## Workflow Routing
- Call \`fn_workflow_list\` to discover available workflows before selecting a routing path, and read each workflow description as the routing signal.
- For investigation, audit, research, or decision-only tasks that produce no code changes, set \`**No commits expected:** true\` in the PROMPT.md header when the no-commits criteria above are met, then select an appropriate lightweight workflow.
- For decision-only tasks (Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report), prefer \`builtin:quick-fix\` or a custom investigation workflow when one is available.
- For standard coding tasks, \`builtin:coding\` is the default and is usually appropriate.
- Use \`fn_workflow_select\` to set the workflow on the current task, or pass \`workflow_id\` to \`fn_task_create\` when creating subtasks.
- Match the task nature to the workflow description; descriptions are authoritative for routing decisions.
## Spec Review ## Spec Review
After writing the PROMPT.md, call \`fn_review_spec()\` to get an independent quality review. After writing the PROMPT.md, call \`fn_review_spec()\` to get an independent quality review.
@@ -582,6 +592,9 @@ Anti-heuristics (bias to false-negative when ambiguous):
## Project commands ## Project commands
When the user prompt includes explicit test/build commands, use those exact commands in the generated spec. When the user prompt includes explicit test/build commands, use those exact commands in the generated spec.
## Workflow Routing
Call \`fn_workflow_list\` and use workflow descriptions as the routing signal. For investigation/audit/research or decision-only tasks that meet the no-commits criteria above, include \`**No commits expected:** true\` in the PROMPT.md header and prefer \`builtin:quick-fix\` or a custom investigation workflow; standard coding tasks can stay on the default \`builtin:coding\`. Use \`fn_workflow_select\` for the current task or pass \`workflow_id\` to \`fn_task_create\` for subtasks.
## Task Artifact Location for Forensic / Reconciliation Tasks ## Task Artifact Location for Forensic / Reconciliation Tasks
For audit/forensic/historical reconciliation tasks that target a different task ID, explicitly state in generated PROMPT.md context/scope that authoritative artifacts and DB state are at project root, not the worktree. For audit/forensic/historical reconciliation tasks that target a different task ID, explicitly state in generated PROMPT.md context/scope that authoritative artifacts and DB state are at project root, not the worktree.
@@ -1183,6 +1196,8 @@ export class TriageProcessor {
}), }),
createTaskDocumentWriteTool(this.store, task.id), createTaskDocumentWriteTool(this.store, task.id),
createTaskDocumentReadTool(this.store, task.id), createTaskDocumentReadTool(this.store, task.id),
createWorkflowListTool(this.store),
createWorkflowSelectTool(this.store, task.id),
...(isResearchToolSurfaceEnabled(settings) ...(isResearchToolSurfaceEnabled(settings)
? createResearchTools({ ? createResearchTools({
store: this.store, store: this.store,
@@ -1875,6 +1890,16 @@ export class TriageProcessor {
description: "Task priority (low, normal, high, urgent)", description: "Task priority (low, normal, high, urgent)",
}), }),
), ),
workflow_id: Type.Optional(
Type.String({
description: "Workflow ID to assign (e.g. 'builtin:coding', 'builtin:quick-fix'). Use fn_workflow_list to discover valid IDs.",
}),
),
noCommitsExpected: Type.Optional(
Type.Boolean({
description: "Set true for investigation/audit/decision tasks that produce no code changes.",
}),
),
}); });
const taskList: ToolDefinition = { const taskList: ToolDefinition = {
@@ -2083,6 +2108,8 @@ export class TriageProcessor {
dependencies: validDeps, dependencies: validDeps,
column: "triage", column: "triage",
priority: params.priority, priority: params.priority,
workflowId: params.workflow_id,
noCommitsExpected: params.noCommitsExpected,
// Inherit parent's model settings if available // Inherit parent's model settings if available
modelProvider: parentTask?.modelProvider, modelProvider: parentTask?.modelProvider,
modelId: parentTask?.modelId, modelId: parentTask?.modelId,