FN-7967: accept custom triage workflow IDs and honor project default

Allow triageDefaultWorkflowId and triageDecisionOnlyWorkflowId to accept custom workflow IDs so project default workflows are honored at triage time.

- Change triage workflow settings from enum to string; empty triageDefaultWorkflowId inherits config.settings.defaultWorkflowId
- Render triage prompt default from project settings unless an explicit stored override exists
- Only pass stored triageDefaultWorkflowId into triage policy settings so declaration defaults do not clobber project defaults
- Document settings behavior and add core/engine regression coverage
- Add patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7967-triage-default-workflow.md                        |  7 +++++++
 docs/settings-reference.md                                           |  4 ++--
 packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++--
 packages/core/src/builtin-workflow-settings.ts                       | 35 ++++++++++++++++++++---------------
 packages/engine/src/__tests__/triage.test.ts                         | 41 +++++++++++++++++++++++++++++++++++++++++
 packages/engine/src/triage.ts                                        | 28 ++++++++++++++++++++++++----
 6 files changed, 135 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-7967

Fusion-Task-Lineage: e42ea061-889c-4bdd-8a9d-f56f34fc0c89

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 14:40:44 -07:00
parent edac617e10
commit f9c19f9f3a
6 changed files with 135 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Honor custom project workflow defaults in triage guidance.
category: fix
dev: `triageDefaultWorkflowId` and `triageDecisionOnlyWorkflowId` now accept custom IDs; unset/default triage routing inherits `config.settings.defaultWorkflowId`.

View File

@@ -370,8 +370,8 @@ The built-in workflows also declare triage/spec policy settings that were **not*
| `triageSubtaskFileScopeThreshold` | `20` | File Scope entry count that signals broad work. |
| `triageSubtaskRemediationBatchThreshold` | `30` | Large remediation batch threshold. |
| `triageNoCommitsDecisionVerbs` | all seven built-ins | Decision-only verbs: Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report. |
| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred workflow for decision-only/no-commit tasks when the user explicitly requests that routing or the agent is creating the task. |
| `triageDefaultWorkflowId` | `builtin:coding` | Default workflow for standard coding tasks and for existing tasks without an explicit user-requested or creator-owned workflow selection. |
| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred built-in or custom workflow for decision-only/no-commit tasks when the user explicitly requests that routing or the agent is creating the task. |
| `triageDefaultWorkflowId` | empty (inherits project default) | Accepts any valid built-in or custom workflow id as a triage override. Empty (and legacy `builtin:coding`) inherits `config.settings.defaultWorkflowId`; the triage prompt falls back to `builtin:coding` only when neither is configured. |
| `leanPlanning` | `false` | Workflow-native fast-mode policy: select the lean `planning-fast` prompt variant instead of the full triage spec prompt. |
| `autoApproveSpec` | `false` | Legacy compatibility setting. Workflow Plan Review now owns optional pre-execution AI plan approval. |
| `planReviewMaxRevisions` | unset | Workflow-native Plan Review/spec revision cap. Unset/empty means unbounded automatic replans; a non-negative integer caps attempts; `0` disables automatic Plan Review revision. |

View File

@@ -8,6 +8,10 @@ import {
renderTriagePolicyPlaceholders,
} from "../builtin-workflow-settings.js";
import { MOVED_SETTINGS_KEYS } from "../moved-settings.js";
import {
resolveEffectiveSettingValues,
validateSettingValuePatch,
} from "../workflow-settings.js";
const expectedDefaults: Record<string, { type: string; default: unknown }> = {
triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true },
@@ -24,8 +28,8 @@ const expectedDefaults: Record<string, { type: string; default: unknown }> = {
type: "multi-enum",
default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"],
},
triageDecisionOnlyWorkflowId: { type: "enum", default: "builtin:quick-fix" },
triageDefaultWorkflowId: { type: "enum", default: "builtin:coding" },
triageDecisionOnlyWorkflowId: { type: "string", default: "builtin:quick-fix" },
triageDefaultWorkflowId: { type: "string", default: "" },
leanPlanning: { type: "boolean", default: false },
autoApproveSpec: { type: "boolean", default: false },
};
@@ -197,6 +201,23 @@ describe("workflow-native built-in workflow settings", () => {
).toBe(false);
});
it("accepts custom triage workflow ids and retains them as effective values", () => {
const patch = validateSettingValuePatch(BUILTIN_TRIAGE_POLICY_SETTINGS, {
triageDefaultWorkflowId: "WF-005",
triageDecisionOnlyWorkflowId: "WF-009",
});
expect(patch.rejections).toEqual([]);
expect(patch.accepted).toMatchObject({
triageDefaultWorkflowId: "WF-005",
triageDecisionOnlyWorkflowId: "WF-009",
});
expect(resolveEffectiveSettingValues(BUILTIN_TRIAGE_POLICY_SETTINGS, patch.accepted)).toMatchObject({
triageDefaultWorkflowId: "WF-005",
triageDecisionOnlyWorkflowId: "WF-009",
});
});
it("renders placeholders from resolved settings and rejects dangling tokens", () => {
const prompt = [
"Size S (<{{triageSizeSmallMaxHours}}h)",
@@ -217,6 +238,24 @@ describe("workflow-native built-in workflow settings", () => {
expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/);
});
it("renders the triage default workflow from project settings unless explicitly overridden", () => {
const prompt = "Keep the project default workflow (`{{triageDefaultWorkflowId}}`)";
expect(renderTriagePolicyPlaceholders(prompt, { defaultWorkflowId: "WF-005" })).toContain("`WF-005`");
expect(renderTriagePolicyPlaceholders(prompt, {
triageDefaultWorkflowId: "builtin:coding",
defaultWorkflowId: "WF-005",
} as never)).toContain("`WF-005`");
expect(renderTriagePolicyPlaceholders(prompt, {
triageDefaultWorkflowId: "WF-009",
defaultWorkflowId: "WF-005",
} as never)).toContain("`WF-009`");
const fallback = renderTriagePolicyPlaceholders(prompt, {});
expect(fallback).toContain("`builtin:coding`");
expect(fallback).not.toContain("{{");
});
it("renders proactive splitting policy as enabled by default", () => {
const rendered = renderTriagePolicyPlaceholders("{{triageProactiveSubtaskSplittingEnabled}}", {});

View File

@@ -406,24 +406,17 @@ export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [
{
id: "triageDecisionOnlyWorkflowId",
name: "Triage decision-only workflow",
type: "enum",
type: "string",
default: "builtin:quick-fix",
options: [
{ value: "builtin:quick-fix", label: "Quick fix" },
{ value: "builtin:coding", label: "Coding" },
],
description: "Preferred workflow id for decision-only or investigation tasks that expect no code changes.",
description: "Preferred built-in or custom workflow id for decision-only or investigation tasks that expect no code changes.",
},
{
id: "triageDefaultWorkflowId",
name: "Triage default workflow",
type: "enum",
default: "builtin:coding",
options: [
{ value: "builtin:coding", label: "Coding" },
{ value: "builtin:quick-fix", label: "Quick fix" },
],
description: "Default workflow id for standard coding tasks.",
type: "string",
default: "",
description:
"Optional built-in or custom workflow id override for triage. Empty inherits config.settings.defaultWorkflowId.",
},
{
id: "leanPlanning",
@@ -602,7 +595,19 @@ const TRIAGE_POLICY_DEFAULTS = new Map(
BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting.default]),
);
function formatTriagePolicyValue(id: string, value: unknown): string {
function formatTriagePolicyValue(id: string, value: unknown, settings: Partial<Settings>): string {
/*
* FNXC:WorkflowRouting 2026-07-15-00:00:
* The triage prompt must name the operator's project default workflow when no
* per-workflow override is configured. This keeps planning guidance aligned
* with config.settings.defaultWorkflowId and accepts custom workflow IDs.
*/
if (id === "triageDefaultWorkflowId") {
const explicitValue = typeof value === "string" ? value.trim() : "";
if (explicitValue && explicitValue !== "builtin:coding") return explicitValue;
const projectDefault = typeof settings.defaultWorkflowId === "string" ? settings.defaultWorkflowId.trim() : "";
return projectDefault || "builtin:coding";
}
if (id === "triageNoCommitsDecisionVerbs") {
const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id);
return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", ");
@@ -647,7 +652,7 @@ export function renderTriagePolicyPlaceholders(prompt: string, settings: Partial
const values = settings as Record<string, unknown>;
for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) {
const token = new RegExp(`\\{\\{${setting.id}\\}\\}`, "g");
rendered = rendered.replace(token, formatTriagePolicyValue(setting.id, values[setting.id] ?? setting.default));
rendered = rendered.replace(token, formatTriagePolicyValue(setting.id, values[setting.id] ?? setting.default, settings));
}
const leftover = rendered.match(/\{\{[^}]+\}\}/);
if (leftover) {

View File

@@ -895,6 +895,9 @@ describe("fast-mode triage", () => {
for (const text of required) expect(prompt).toContain(text);
for (const text of forbidden) expect(prompt).not.toContain(text);
}
expect(renderTriagePolicyPlaceholders(TRIAGE_POLICY_PROMPT, { defaultWorkflowId: "WF-005" })).toContain(
"Keep the project default workflow (`WF-005`)",
);
});
it("includes task-artifact location guidance for forensic/reconciliation tasks", () => {
@@ -958,6 +961,44 @@ describe("fast-mode triage", () => {
expect(capturedSystemPrompt).toContain("## Review Level");
});
it("renders a stored triage workflow override over the configured project default", async () => {
const task = createTriageTask({ id: "FN-7967", executionMode: "standard" });
const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultWorkflowId: "WF-005",
} as Settings),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-1"),
getWorkflowSettingValues: vi.fn().mockReturnValue({ triageDefaultWorkflowId: "WF-009" }),
});
let capturedSystemPrompt = "";
mockCreateFnAgent.mockImplementationOnce(async (opts: any) => {
capturedSystemPrompt = opts.systemPrompt;
return {
session: {
state: {},
sessionManager: { getLeafId: vi.fn().mockReturnValue(null) },
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
navigateTree: vi.fn(),
},
};
});
const processor = new TriageProcessor(store, "/tmp/root");
await processor.specifyTask(task);
expect(capturedSystemPrompt).toContain("Keep the project default workflow (`WF-009`)");
expect(capturedSystemPrompt).not.toContain("Keep the project default workflow (`WF-005`)");
});
it("renders disabled proactive splitting while preserving explicit breakIntoSubtasks prompts", async () => {
const task = createTriageTask({ id: "FN-FAST-003", executionMode: "standard", breakIntoSubtasks: true });
const rootDir = await createTriageFixtureRoot("fn-7491-triage-");

View File

@@ -20,7 +20,7 @@ import {
resolveAgentPrompt,
builtinSeamPrompt,
renderTriagePolicyPlaceholders,
resolveEffectiveSettings,
resolveEffectiveSettingsDetailed,
resolveEffectivePlannerHeartbeatPatrolEnabled,
resolveTaskPlanningPrompt,
resolveTaskSeamPrompt,
@@ -1144,8 +1144,28 @@ export class TriageProcessor {
const workflowFastPlanningPrompt = leanPlanning
? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined)
: undefined;
const effectiveWorkflowSettings = await resolveEffectiveSettings(this.store, task).catch(() => ({}));
const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(effectiveWorkflowSettings);
const resolvedWorkflowSettings = await resolveEffectiveSettingsDetailed(this.store, task).catch((): {
effective: Record<string, unknown>;
storedKeys: Set<string>;
} => ({
effective: {},
storedKeys: new Set<string>(),
}));
const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(resolvedWorkflowSettings.effective);
/*
* FNXC:WorkflowRouting 2026-07-15-13:00:
* Triage policy values are workflow-scoped, while defaultWorkflowId remains
* project-scoped. Only an explicitly stored triageDefaultWorkflowId may
* override the project settings; a declaration default must inherit the
* project default and must not clobber legacy triage policy values.
*/
const triageDefaultWorkflowId = resolvedWorkflowSettings.storedKeys.has("triageDefaultWorkflowId")
? resolvedWorkflowSettings.effective.triageDefaultWorkflowId
: undefined;
const triagePolicySettings = {
...settings,
...(triageDefaultWorkflowId === undefined ? {} : { triageDefaultWorkflowId }),
} as Partial<Settings>;
// FN-6232: standard-mode built-in triage policy is sourced from the workflow IR planning node; the former engine duplicate was removed.
const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage
? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled })
@@ -1158,7 +1178,7 @@ export class TriageProcessor {
// Apply the workflow-native triage policy renderer to both standard and
// fast prompts. Fast mode currently has no policy placeholders, making
// this a no-op there while still guaranteeing no dangling token leaks.
const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, settings);
const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, triagePolicySettings);
const triageLayers = buildPromptLayers({
basePrompt: renderedBasePrompt,
goalContext: triageGoalResolution.goalContext,