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:
7
.changeset/fn-7967-triage-default-workflow.md
Normal file
7
.changeset/fn-7967-triage-default-workflow.md
Normal 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`.
|
||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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}}", {});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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-");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user