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. | | `triageSubtaskFileScopeThreshold` | `20` | File Scope entry count that signals broad work. |
| `triageSubtaskRemediationBatchThreshold` | `30` | Large remediation batch threshold. | | `triageSubtaskRemediationBatchThreshold` | `30` | Large remediation batch threshold. |
| `triageNoCommitsDecisionVerbs` | all seven built-ins | Decision-only verbs: Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report. | | `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. | | `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` | `builtin:coding` | Default workflow for standard coding tasks and for existing tasks without an explicit user-requested or creator-owned workflow selection. | | `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. | | `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. | | `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. | | `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, renderTriagePolicyPlaceholders,
} from "../builtin-workflow-settings.js"; } from "../builtin-workflow-settings.js";
import { MOVED_SETTINGS_KEYS } from "../moved-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 }> = { const expectedDefaults: Record<string, { type: string; default: unknown }> = {
triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true }, triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true },
@@ -24,8 +28,8 @@ const expectedDefaults: Record<string, { type: string; default: unknown }> = {
type: "multi-enum", type: "multi-enum",
default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"], default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"],
}, },
triageDecisionOnlyWorkflowId: { type: "enum", default: "builtin:quick-fix" }, triageDecisionOnlyWorkflowId: { type: "string", default: "builtin:quick-fix" },
triageDefaultWorkflowId: { type: "enum", default: "builtin:coding" }, triageDefaultWorkflowId: { type: "string", default: "" },
leanPlanning: { type: "boolean", default: false }, leanPlanning: { type: "boolean", default: false },
autoApproveSpec: { type: "boolean", default: false }, autoApproveSpec: { type: "boolean", default: false },
}; };
@@ -197,6 +201,23 @@ describe("workflow-native built-in workflow settings", () => {
).toBe(false); ).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", () => { it("renders placeholders from resolved settings and rejects dangling tokens", () => {
const prompt = [ const prompt = [
"Size S (<{{triageSizeSmallMaxHours}}h)", "Size S (<{{triageSizeSmallMaxHours}}h)",
@@ -217,6 +238,24 @@ describe("workflow-native built-in workflow settings", () => {
expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/); 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", () => { it("renders proactive splitting policy as enabled by default", () => {
const rendered = renderTriagePolicyPlaceholders("{{triageProactiveSubtaskSplittingEnabled}}", {}); const rendered = renderTriagePolicyPlaceholders("{{triageProactiveSubtaskSplittingEnabled}}", {});

View File

@@ -406,24 +406,17 @@ export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [
{ {
id: "triageDecisionOnlyWorkflowId", id: "triageDecisionOnlyWorkflowId",
name: "Triage decision-only workflow", name: "Triage decision-only workflow",
type: "enum", type: "string",
default: "builtin:quick-fix", default: "builtin:quick-fix",
options: [ description: "Preferred built-in or custom workflow id for decision-only or investigation tasks that expect no code changes.",
{ 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.",
}, },
{ {
id: "triageDefaultWorkflowId", id: "triageDefaultWorkflowId",
name: "Triage default workflow", name: "Triage default workflow",
type: "enum", type: "string",
default: "builtin:coding", default: "",
options: [ description:
{ value: "builtin:coding", label: "Coding" }, "Optional built-in or custom workflow id override for triage. Empty inherits config.settings.defaultWorkflowId.",
{ value: "builtin:quick-fix", label: "Quick fix" },
],
description: "Default workflow id for standard coding tasks.",
}, },
{ {
id: "leanPlanning", id: "leanPlanning",
@@ -602,7 +595,19 @@ const TRIAGE_POLICY_DEFAULTS = new Map(
BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting.default]), 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") { if (id === "triageNoCommitsDecisionVerbs") {
const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id); const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id);
return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", "); 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>; const values = settings as Record<string, unknown>;
for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) { for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) {
const token = new RegExp(`\\{\\{${setting.id}\\}\\}`, "g"); 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(/\{\{[^}]+\}\}/); const leftover = rendered.match(/\{\{[^}]+\}\}/);
if (leftover) { 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 required) expect(prompt).toContain(text);
for (const text of forbidden) expect(prompt).not.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", () => { it("includes task-artifact location guidance for forensic/reconciliation tasks", () => {
@@ -958,6 +961,44 @@ describe("fast-mode triage", () => {
expect(capturedSystemPrompt).toContain("## Review Level"); 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 () => { it("renders disabled proactive splitting while preserving explicit breakIntoSubtasks prompts", async () => {
const task = createTriageTask({ id: "FN-FAST-003", executionMode: "standard", breakIntoSubtasks: true }); const task = createTriageTask({ id: "FN-FAST-003", executionMode: "standard", breakIntoSubtasks: true });
const rootDir = await createTriageFixtureRoot("fn-7491-triage-"); const rootDir = await createTriageFixtureRoot("fn-7491-triage-");

View File

@@ -20,7 +20,7 @@ import {
resolveAgentPrompt, resolveAgentPrompt,
builtinSeamPrompt, builtinSeamPrompt,
renderTriagePolicyPlaceholders, renderTriagePolicyPlaceholders,
resolveEffectiveSettings, resolveEffectiveSettingsDetailed,
resolveEffectivePlannerHeartbeatPatrolEnabled, resolveEffectivePlannerHeartbeatPatrolEnabled,
resolveTaskPlanningPrompt, resolveTaskPlanningPrompt,
resolveTaskSeamPrompt, resolveTaskSeamPrompt,
@@ -1144,8 +1144,28 @@ export class TriageProcessor {
const workflowFastPlanningPrompt = leanPlanning const workflowFastPlanningPrompt = leanPlanning
? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined) ? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined)
: undefined; : undefined;
const effectiveWorkflowSettings = await resolveEffectiveSettings(this.store, task).catch(() => ({})); const resolvedWorkflowSettings = await resolveEffectiveSettingsDetailed(this.store, task).catch((): {
const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(effectiveWorkflowSettings); 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. // 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 const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage
? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled }) ? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled })
@@ -1158,7 +1178,7 @@ export class TriageProcessor {
// Apply the workflow-native triage policy renderer to both standard and // Apply the workflow-native triage policy renderer to both standard and
// fast prompts. Fast mode currently has no policy placeholders, making // fast prompts. Fast mode currently has no policy placeholders, making
// this a no-op there while still guaranteeing no dangling token leaks. // 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({ const triageLayers = buildPromptLayers({
basePrompt: renderedBasePrompt, basePrompt: renderedBasePrompt,
goalContext: triageGoalResolution.goalContext, goalContext: triageGoalResolution.goalContext,