feat(FN-5205): add workflow-step test mode dispatch and routing for mock pr

Implements workflow step test mode (FN-5205) by wiring mock dispatch, context forwarding, and routing through executor, merger, and mock provider, plus adding corresponding tests and docs. Also adds broad-scope triage heuristics to improve task-scope detection, touching triage.ts, triage-broad-scope

Fusion-Task-Id: FN-5205

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5205
This commit is contained in:
gsxdsm
2026-05-23 02:15:36 -07:00
parent b22112af89
commit 76bd3a7d90
19 changed files with 808 additions and 23 deletions

View File

@@ -126,6 +126,26 @@ describe("createResolvedAgentSession", () => {
}),
);
});
it("forwards workflow step runtime context fields to mock sessions", async () => {
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: undefined,
cwd: "/tmp/project",
systemPrompt: "system",
defaultProvider: "mock",
runtimeContext: {
workflowStepId: "WS-004",
workflowStepTemplateId: "browser-verification",
},
});
const mockMeta = (result.session as unknown as { __mock?: { workflowStepId?: string; workflowStepTemplateId?: string } }).__mock;
expect(mockMeta?.workflowStepId).toBe("WS-004");
expect(mockMeta?.workflowStepTemplateId).toBe("browser-verification");
});
});
describe("resolveMergerSessionModel", () => {

View File

@@ -27,6 +27,7 @@ import {
clearMockScript,
resetMockScripts,
setMockScript,
resolveMockScript,
} from "../providers/mock-provider.js";
function createTool(name: string, execute = vi.fn().mockResolvedValue({ content: [], details: {} })): ToolDefinition {
@@ -67,6 +68,7 @@ describe("MockAgentRuntime", () => {
["merger", []],
["heartbeat", []],
["validation", []],
["workflow-step", []],
] as const)("runs the default %s script deterministically", async (sessionPurpose, expectedCalls) => {
const runtime = new MockAgentRuntime();
const { cwd, taskDir, taskId } = await createWorkspace();
@@ -128,6 +130,34 @@ describe("MockAgentRuntime", () => {
if (sessionPurpose === "reviewer" || sessionPurpose === "validation") {
expect(onText).toHaveBeenCalledWith(expect.stringContaining("Verdict: APPROVE"));
}
if (sessionPurpose === "workflow-step") {
expect(onText).toHaveBeenCalledWith(expect.stringContaining('{"verdict":"APPROVE","notes":""}'));
}
});
it("resolves mock script overrides by specificity precedence", async () => {
const defaultScript = resolveMockScript({ sessionPurpose: "workflow-step" });
const purposeOnly = { run: vi.fn(async () => undefined) };
const templateOnly = { run: vi.fn(async () => undefined) };
const taskOnly = { run: vi.fn(async () => undefined) };
const taskAndTemplate = { run: vi.fn(async () => undefined) };
setMockScript({ sessionPurpose: "workflow-step" }, purposeOnly);
setMockScript({ sessionPurpose: "workflow-step", workflowStepTemplateId: "browser-verification" }, templateOnly);
setMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1" }, taskOnly);
setMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "browser-verification" }, taskAndTemplate);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "browser-verification" })).toBe(taskAndTemplate);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "other-template" })).toBe(taskOnly);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-2", workflowStepTemplateId: "browser-verification" })).toBe(templateOnly);
expect(resolveMockScript({ sessionPurpose: "workflow-step", taskId: "FN-2", workflowStepTemplateId: "other-template" })).toBe(purposeOnly);
expect(resolveMockScript({ sessionPurpose: "workflow-step" })).toBe(purposeOnly);
clearMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1", workflowStepTemplateId: "browser-verification" });
clearMockScript({ sessionPurpose: "workflow-step", taskId: "FN-1" });
clearMockScript({ sessionPurpose: "workflow-step", workflowStepTemplateId: "browser-verification" });
clearMockScript({ sessionPurpose: "workflow-step" });
expect(resolveMockScript({ sessionPurpose: "workflow-step" })).toBe(defaultScript);
});
it("prefers a task-scoped override over the default script", async () => {
@@ -200,7 +230,7 @@ describe("MockAgentRuntime", () => {
throw new Error("https.request should not be called");
});
for (const sessionPurpose of ["executor", "triage", "reviewer", "merger", "heartbeat", "validation"] as const) {
for (const sessionPurpose of ["executor", "triage", "reviewer", "merger", "heartbeat", "validation", "workflow-step"] as const) {
const { cwd, taskId } = await createWorkspace(`FN-${sessionPurpose}`);
const { session } = await runtime.createSession({
cwd,

View File

@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { execSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DEFAULT_SETTINGS, TaskStore } from "@fusion/core";
import * as broadScopeHeuristics from "../../triage-broad-scope-heuristics.js";
import { TriageProcessor } from "../../triage.js";
function git(cwd: string, command: string): string {
return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
async function createFixture() {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-broad-scope-triage-"));
git(rootDir, "git init -b main");
git(rootDir, 'git config user.email "test@example.com"');
git(rootDir, 'git config user.name "Test User"');
git(rootDir, "git commit --allow-empty -m init");
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
await store.updateSettings({ ...DEFAULT_SETTINGS, requirePlanApproval: false });
const triage = new TriageProcessor(store, rootDir);
return {
rootDir,
store,
triage,
persistPrompt: async (taskId: string, prompt: string) => {
await mkdir(join(rootDir, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(join(rootDir, ".fusion", "tasks", taskId, "PROMPT.md"), prompt, "utf-8");
},
cleanup: async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
},
};
}
function buildPrompt({ size, stepCount, fileScopeCount }: { size: "S" | "M" | "L"; stepCount: number; fileScopeCount: number }): string {
const steps = Array.from({ length: stepCount }, (_, index) => `### Step ${index + 1}: Step ${index + 1}\n- [ ] do work ${index + 1}`)
.join("\n\n");
const fileScope = Array.from({ length: fileScopeCount }, (_, index) => `- ` + "`" + `packages/engine/src/generated/file-${index + 1}.ts` + "`")
.join("\n");
return `# Task: FN-1 - test\n\n**Size:** ${size}\n\n## Review Level: 1\n\n## File Scope\n${fileScope}\n\n## Steps\n\n${steps}\n`;
}
describe("reliability interactions: broad-scope triage flag", () => {
const fixtures: Array<Awaited<ReturnType<typeof createFixture>>> = [];
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
while (fixtures.length) await fixtures.pop()!.cleanup();
});
it("adds broadScopeFlag metadata and preserves intentSignature/fileScope composition", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.sourceMetadata?.broadScopeFlag).toMatchObject({
score: 9,
reasons: expect.arrayContaining(["size-l", "steps-high", "file-scope-high", "failing-file-mentions-high", "size-l-with-many-steps"]),
signals: expect.objectContaining({
size: "L",
stepCount: 12,
fileScopeCount: 25,
failingFileMentions: 30,
}),
thresholds: expect.objectContaining({
stepsHigh: 12,
fileScopeHigh: 20,
failingFileMentionsHigh: 30,
sizeLStepsThreshold: 9,
}),
version: 1,
flaggedAt: expect.any(String),
});
expect(updated.sourceMetadata?.intentSignature).toBeTruthy();
expect(updated.sourceMetadata?.fileScope).toHaveLength(25);
});
it("keeps flagged tasks in todo because the flag is advisory only", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.column).toBe("todo");
});
it("emits a run-audit event with the broad-scope metadata payload", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
expect(audit).toEqual(expect.arrayContaining([
expect.objectContaining({
mutationType: "task:broad-scope-flagged-at-triage",
metadata: expect.objectContaining({
score: 9,
reasons: expect.arrayContaining(["size-l", "steps-high", "file-scope-high", "failing-file-mentions-high", "size-l-with-many-steps"]),
signals: expect.objectContaining({ size: "L", stepCount: 12, fileScopeCount: 25, failingFileMentions: 30 }),
thresholds: expect.objectContaining({
stepsHigh: 12,
fileScopeHigh: 20,
failingFileMentionsHigh: 30,
sizeLStepsThreshold: 9,
}),
version: 1,
}),
}),
]));
});
it("appends an operator log entry when the flag fires", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(true);
});
it("does not add flag metadata, audit, or log entry for small narrow tasks", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Fix one narrow regression",
description: "Touches auth.ts only.",
});
const prompt = buildPrompt({ size: "S", stepCount: 4, fileScopeCount: 3 });
await fx.persistPrompt(task.id, prompt);
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.column).toBe("todo");
expect(updated.sourceMetadata?.broadScopeFlag).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(false);
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
expect(audit.some((entry) => entry.mutationType === "task:broad-scope-flagged-at-triage")).toBe(false);
});
it("fails open when signal extraction throws", async () => {
const fx = await createFixture();
fixtures.push(fx);
const task = await fx.store.createTask({
title: "Repair engine regression across generated files",
description: "Touches /api/tasks/:id/pr/options, auth.ts, and 30 failing files across the triage pipeline.",
});
const prompt = buildPrompt({ size: "L", stepCount: 12, fileScopeCount: 25 });
await fx.persistPrompt(task.id, prompt);
vi.spyOn(broadScopeHeuristics, "extractBroadScopeSignals").mockImplementation(() => {
throw new Error("boom");
});
await (fx.triage as any).finalizeApprovedTask(task, prompt, await fx.store.getSettings(), {});
const updated = await fx.store.getTask(task.id);
expect(updated.column).toBe("todo");
expect(updated.sourceMetadata?.broadScopeFlag).toBeUndefined();
expect(updated.log.some((entry) => entry.action === "Broad-scope triage flag")).toBe(false);
const audit = fx.store.getRunAuditEvents({ taskId: task.id, limit: 20 });
expect(audit.some((entry) => entry.mutationType === "task:broad-scope-flagged-at-triage")).toBe(false);
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
BROAD_SCOPE_FLAG_VERSION,
decideBroadScopeFlag,
extractBroadScopeSignals,
} from "../triage-broad-scope-heuristics.js";
describe("triage broad-scope heuristics", () => {
describe("extractBroadScopeSignals", () => {
it("uses the largest matching multi-digit failing-file mention", () => {
const signals = extractBroadScopeSignals({
size: "M",
stepCount: 5,
fileScopeCount: 4,
descriptionText: "Touches 12 failing files, 30 broken tests, and 21 files overall. Ignore 7 failing files.",
});
expect(signals).toMatchObject({
size: "M",
stepCount: 5,
fileScopeCount: 4,
failingFileMentions: 30,
});
});
it("caps pathological counts at 9999", () => {
const signals = extractBroadScopeSignals({
size: "L",
stepCount: 14,
fileScopeCount: 24,
descriptionText: "Spec mentions 12345 failing files across 200 broken tests.",
});
expect(signals.failingFileMentions).toBe(9999);
});
it("returns zero when there are no qualifying mentions", () => {
const signals = extractBroadScopeSignals({
size: "S",
stepCount: 2,
fileScopeCount: 1,
descriptionText: "Only 9 failing files are listed, plus one broken test.",
});
expect(signals.failingFileMentions).toBe(0);
});
});
describe("decideBroadScopeFlag", () => {
it("does not flag small low-scope tasks", () => {
const decision = decideBroadScopeFlag({
size: "S",
stepCount: 4,
fileScopeCount: 3,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(false);
expect(decision.score).toBe(0);
expect(decision.reasons).toEqual([]);
});
it("does not flag size L alone", () => {
const decision = decideBroadScopeFlag({
size: "L",
stepCount: 4,
fileScopeCount: 3,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(false);
expect(decision.score).toBe(2);
expect(decision.reasons).toEqual(["size-l"]);
});
it("flags size L tasks with many steps", () => {
const decision = decideBroadScopeFlag({
size: "L",
stepCount: 12,
fileScopeCount: 3,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(true);
expect(decision.score).toBe(5);
expect(decision.reasons).toEqual(["size-l", "steps-high", "size-l-with-many-steps"]);
});
it("does not flag high file scope alone", () => {
const decision = decideBroadScopeFlag({
size: "M",
stepCount: 5,
fileScopeCount: 21,
failingFileMentions: 0,
});
expect(decision.flagged).toBe(false);
expect(decision.score).toBe(2);
expect(decision.reasons).toEqual(["file-scope-high"]);
});
it("flags when multiple strong signals combine", () => {
const decision = decideBroadScopeFlag({
size: "M",
stepCount: 5,
fileScopeCount: 21,
failingFileMentions: 30,
});
expect(decision.flagged).toBe(true);
expect(decision.score).toBe(4);
expect(decision.reasons).toEqual(["file-scope-high", "failing-file-mentions-high"]);
});
});
it("exports the initial heuristic version", () => {
expect(BROAD_SCOPE_FLAG_VERSION).toBe(1);
});
});

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor, parseWorkflowStepVerdict } from "../executor.js";
import { mockedCreateFnAgent, createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
function buildTask() {
return {
id: "FN-5205",
title: "Workflow test",
description: "",
column: "in-progress" as const,
dependencies: [],
steps: [{ name: "Preflight", status: "done" as const }],
currentStep: 0,
log: [],
enabledWorkflowSteps: ["WS-004"],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function buildStep() {
return {
id: "WS-004",
templateId: "browser-verification",
name: "Browser Verification",
mode: "prompt",
toolMode: "readonly",
prompt: "verify",
gateMode: "gate",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function scriptedSession(output: string) {
const subscribers: Array<(event: any) => void> = [];
return {
state: {},
subscribe: (cb: (event: any) => void) => subscribers.push(cb),
prompt: vi.fn(async () => {
subscribers.forEach((cb) => cb({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: output } }));
}),
dispose: vi.fn(),
getSessionStats: () => ({ tokens: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } }),
};
}
describe("workflow-step test mode routing", () => {
beforeEach(() => {
resetExecutorMocks();
});
it("FN-5205 test mode browser verification defaults to APPROVE", async () => {
const store = createMockStore();
const task = buildTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
vi.spyOn(TaskExecutor.prototype as any, "captureModifiedFiles").mockResolvedValue([]);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"APPROVE","notes":""}\n') as any, sessionFile: undefined } as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { testMode: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
expect(result.allPassed).toBe(true);
const args = mockedCreateFnAgent.mock.calls.at(-1)?.[0] as any;
expect(args?.defaultProvider).toBe("mock");
expect(args?.defaultModelId).toBe("scripted");
expect(args?.runtimeContext).toEqual({ workflowStepId: "WS-004", workflowStepTemplateId: "browser-verification" });
const parsed = parseWorkflowStepVerdict('{"verdict":"APPROVE","notes":""}');
expect(parsed?.verdict).toBe("APPROVE");
});
it("test mode per-template REVISE override blocks merge path", async () => {
const store = createMockStore();
const task = buildTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
vi.spyOn(TaskExecutor.prototype as any, "captureModifiedFiles").mockResolvedValue([]);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"REVISE","notes":"forced FAIL via FN-5205 override"}\n') as any, sessionFile: undefined } as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
const result = await (executor as any).runWorkflowSteps(task as any, "/tmp/test", { testMode: true, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
expect(result).toEqual(expect.objectContaining({ allPassed: false, revisionRequested: true, stepName: "Browser Verification" }));
expect(String((result as any).feedback)).toContain("forced FAIL");
});
it("test mode off preserves real-provider selection while forwarding template context", async () => {
const store = createMockStore();
const task = buildTask();
store.getTask.mockResolvedValue(task as any);
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"APPROVE","notes":""}\n') as any, sessionFile: undefined } as any);
await (executor as any).executeWorkflowStep(task as any, buildStep() as any, "/tmp/test", {
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
}, undefined);
const args = mockedCreateFnAgent.mock.calls.at(-1)?.[0] as any;
expect(args?.defaultProvider).toBe("anthropic");
expect(args?.runtimeContext?.workflowStepTemplateId).toBe("browser-verification");
});
it("FN-5205 mock workflow-step path does not spawn browser harness or bash tools", async () => {
const store = createMockStore();
const task = buildTask();
store.getWorkflowStep.mockResolvedValue(buildStep() as any);
mockedCreateFnAgent.mockResolvedValue({ session: scriptedSession('{"verdict":"APPROVE","notes":""}\n') as any, sessionFile: undefined } as any);
const executor = new TaskExecutor(store as any, "/tmp/test", {} as any);
await (executor as any).executeWorkflowStep(task as any, buildStep() as any, "/tmp/test", { testMode: true, defaultProvider: "mock", defaultModelId: "scripted" }, undefined);
const args = mockedCreateFnAgent.mock.calls.at(-1)?.[0] as any;
expect(args?.tools).toBe("readonly");
expect((args?.customTools ?? []).some((tool: { name?: string }) => {
const name = String(tool.name ?? "");
return name === "bash" || name === "Bash" || name.startsWith("agent-browser");
})).toBe(false);
});
});

View File

@@ -30,6 +30,8 @@ export interface AgentRuntimeContext {
toolMode?: "coding" | "readonly";
customToolNames?: string[];
requestedSkillNames?: string[];
workflowStepId?: string;
workflowStepTemplateId?: string;
}
export interface AgentRuntimeOptions {

View File

@@ -280,6 +280,8 @@ export async function createResolvedAgentSession(
runtimeContext: {
...runtimeOptions.runtimeContext,
sessionPurpose,
workflowStepId: runtimeOptions.runtimeContext?.workflowStepId,
workflowStepTemplateId: runtimeOptions.runtimeContext?.workflowStepTemplateId,
},
}
: runtimeOptions;

View File

@@ -84,6 +84,7 @@ import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./re
import type { StuckTaskDetector, StuckTaskEvent } from "./stuck-task-detector.js";
import type { PluginRunner } from "./plugin-runner.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { isMockProviderId } from "./runtime-resolution.js";
import { StepSessionExecutor } from "./step-session-executor.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
@@ -7834,18 +7835,31 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
// fallback is the per-step override's missing-counterpart settings, then
// the global validator/fallback pair, then the executor's `fallbackProvider`.
const defaultModel = resolveProjectDefaultModel(settings);
const primaryProvider = workflowStep.modelProvider || defaultModel.provider;
const primaryModelId = workflowStep.modelId || defaultModel.modelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
let primaryProvider = workflowStep.modelProvider || defaultModel.provider;
let primaryModelId = workflowStep.modelId || defaultModel.modelId;
let useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
const testMode = (settings as { testMode?: boolean }).testMode === true
|| isMockProviderId(primaryProvider)
|| isMockProviderId(settings.defaultProvider);
if (testMode) {
primaryProvider = "mock";
primaryModelId = "scripted";
useOverride = false;
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model: mock/scripted (test mode)`);
await this.store.logEntry(task.id, `Workflow step '${workflowStep.name}' using model: mock/scripted (test mode)`);
}
type ModelTuple = { provider?: string; modelId?: string };
const fallbackCandidates: Array<ModelTuple & { label: string }> = [
{ provider: settings.validatorFallbackProvider, modelId: settings.validatorFallbackModelId, label: "validatorFallback" },
{ provider: settings.fallbackProvider, modelId: settings.fallbackModelId, label: "globalFallback" },
];
const fallback = fallbackCandidates.find(
(c) => c.provider && c.modelId && (c.provider !== primaryProvider || c.modelId !== primaryModelId),
);
const fallback = testMode
? undefined
: fallbackCandidates.find(
(c) => c.provider && c.modelId && (c.provider !== primaryProvider || c.modelId !== primaryModelId),
);
const timeoutMs = Math.max(60_000, settings.workflowStepTimeoutMs ?? 360_000);
@@ -7881,6 +7895,10 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
);
}
const workflowRuntimeContext = {
workflowStepId: workflowStep.id,
workflowStepTemplateId: workflowStep.templateId ?? workflowStep.id,
};
const { session } = await createResolvedAgentSession({
sessionPurpose: "executor",
runtimeHint: workflowRuntimeHint,
@@ -7890,13 +7908,15 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
tools: toolMode,
defaultProvider: provider,
defaultModelId: modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: testMode ? undefined : settings.fallbackProvider,
fallbackModelId: testMode ? undefined : settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
taskEnv,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}),
// Test-mode routing (FN-5205): mock dispatcher keys on workflowStepTemplateId; real providers ignore.
runtimeContext: workflowRuntimeContext,
});
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`);

View File

@@ -88,6 +88,7 @@ import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js";
import { createFallbackModelObserver } from "./fallback-model-observer.js";
import { isMockProviderId } from "./runtime-resolution.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { classifyTaskWorktree, RemovalReason, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { activeSessionRegistry } from "./active-session-registry.js";
@@ -11460,9 +11461,20 @@ If issues are found that need attention, describe them clearly and include concr
? await agentStoreWithGetAgent.getAgent(assignedAgentId).catch(() => null)
: null;
const mergerSessionModel = resolveMergerSessionModel(settings, assignedAgent?.runtimeConfig);
const stepProvider = workflowStep.modelProvider || mergerSessionModel.provider;
const stepModelId = workflowStep.modelId || mergerSessionModel.modelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
let stepProvider = workflowStep.modelProvider || mergerSessionModel.provider;
let stepModelId = workflowStep.modelId || mergerSessionModel.modelId;
let useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
const testMode = (settings as { testMode?: boolean }).testMode === true
|| isMockProviderId(stepProvider)
|| isMockProviderId(settings.defaultProvider);
if (testMode) {
stepProvider = "mock";
stepModelId = "scripted";
useOverride = false;
mergerLog.log(`${taskId}: [post-merge] workflow step '${workflowStep.name}' using model: mock/scripted (test mode)`);
await store.logEntry(taskId, `[post-merge] Workflow step '${workflowStep.name}' using model: mock/scripted (test mode)`);
}
// Post-merge step agents inherit merger instructions
let postMergeInstructions = "";
@@ -11491,6 +11503,10 @@ If issues are found that need attention, describe them clearly and include concr
`[readonly-violation] Post-merge workflow step '${workflowStep.name}' dropped denied custom tools: ${readonlyCustomTools.denied.join(", ")}`,
);
}
const workflowRuntimeContext = {
workflowStepId: workflowStep.id,
workflowStepTemplateId: workflowStep.templateId ?? workflowStep.id,
};
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
runtimeHint: mergerRuntimeHint,
@@ -11500,13 +11516,15 @@ If issues are found that need attention, describe them clearly and include concr
tools: toolMode,
defaultProvider: stepProvider,
defaultModelId: stepModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
fallbackProvider: testMode ? undefined : settings.fallbackProvider,
fallbackModelId: testMode ? undefined : settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(postMergeSkillContext?.skillSelectionContext ? { skillSelection: postMergeSkillContext.skillSelectionContext } : {}),
...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}),
taskId,
// Test-mode routing (FN-5205): mock dispatcher keys on workflowStepTemplateId; real providers ignore.
runtimeContext: workflowRuntimeContext,
onFallbackModelUsed: createFallbackModelObserver({
agent: "merger",
label: `post-merge workflow step '${workflowStep.name}'`,

View File

@@ -45,6 +45,8 @@ export interface MockScriptContext {
tools: ToolDefinition[];
taskId?: string;
taskTitle?: string;
workflowStepId?: string;
workflowStepTemplateId?: string;
invokeTool(name: string, args: Record<string, unknown>): Promise<unknown>;
}
@@ -55,10 +57,11 @@ export interface MockScript {
interface MockScriptKey {
sessionPurpose: MockSessionPurpose;
taskId?: string;
workflowStepTemplateId?: string;
}
function registryKey({ sessionPurpose, taskId }: MockScriptKey): string {
return `${sessionPurpose}:${taskId ?? "*"}`;
function registryKey({ sessionPurpose, taskId, workflowStepTemplateId }: MockScriptKey): string {
return `${sessionPurpose}:${taskId ?? "*"}:${workflowStepTemplateId ?? "*"}`;
}
const overrides = new Map<string, MockScript>();
@@ -75,6 +78,8 @@ export const mockScriptRegistry = {
},
resolveMockScript(key: MockScriptKey): MockScript {
return overrides.get(registryKey(key))
?? overrides.get(registryKey({ sessionPurpose: key.sessionPurpose, taskId: key.taskId }))
?? overrides.get(registryKey({ sessionPurpose: key.sessionPurpose, workflowStepTemplateId: key.workflowStepTemplateId }))
?? overrides.get(registryKey({ sessionPurpose: key.sessionPurpose }))
?? DEFAULT_SCRIPTS[key.sessionPurpose];
},
@@ -90,6 +95,8 @@ let toolCallCounter = 0;
interface MockAgentSessionState {
sessionPurpose: MockSessionPurpose;
options: AgentRuntimeOptions;
workflowStepId?: string;
workflowStepTemplateId?: string;
}
interface MockToolCallResult {
@@ -100,8 +107,13 @@ export class MockAgentSession {
readonly __mock: MockAgentSessionState;
readonly state: { errorMessage?: string; error?: string } = {};
constructor(options: AgentRuntimeOptions, sessionPurpose: MockSessionPurpose) {
this.__mock = { options, sessionPurpose };
constructor(
options: AgentRuntimeOptions,
sessionPurpose: MockSessionPurpose,
workflowStepId?: string,
workflowStepTemplateId?: string,
) {
this.__mock = { options, sessionPurpose, workflowStepId, workflowStepTemplateId };
}
dispose(): void {}
@@ -235,6 +247,11 @@ const DEFAULT_SCRIPTS: Record<MockSessionPurpose, MockScript> = {
ctx.options.onText?.("Verdict: APPROVE\n\nSummary: Mock validation passed.\n");
},
},
"workflow-step": {
async run(ctx) {
ctx.options.onText?.("Mock workflow-step approved scripted run.\n{\"verdict\":\"APPROVE\",\"notes\":\"\"}\n");
},
},
};
export class MockAgentRuntime implements AgentRuntime {
@@ -243,20 +260,30 @@ export class MockAgentRuntime implements AgentRuntime {
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
await options.beforeSpawnSession?.();
const sessionPurpose = (options.runtimeContext?.sessionPurpose as SessionPurpose | undefined) ?? "executor";
const runtimeContext = options.runtimeContext as
| { sessionPurpose?: SessionPurpose; workflowStepId?: string; workflowStepTemplateId?: string }
| undefined;
const workflowStepId = runtimeContext?.workflowStepId;
const workflowStepTemplateId = runtimeContext?.workflowStepTemplateId;
let sessionPurpose: MockSessionPurpose = (runtimeContext?.sessionPurpose as SessionPurpose | undefined) ?? "executor";
// FN-5205: workflow-step template routing overrides lane purpose for mock script dispatch.
if (workflowStepTemplateId) {
sessionPurpose = "workflow-step";
}
return {
session: new MockAgentSession(options, sessionPurpose) as unknown as AgentSession,
session: new MockAgentSession(options, sessionPurpose, workflowStepId, workflowStepTemplateId) as unknown as AgentSession,
sessionFile: undefined,
};
}
async promptWithFallback(session: AgentSession, prompt: string, _promptOptions?: unknown): Promise<void> {
const mockSession = session as unknown as MockAgentSession;
const { options, sessionPurpose } = mockSession.__mock;
const { options, sessionPurpose, workflowStepId, workflowStepTemplateId } = mockSession.__mock;
const tools = options.customTools ?? [];
const script = mockScriptRegistry.resolveMockScript({
sessionPurpose,
taskId: options.taskId,
workflowStepTemplateId,
});
await script.run({
sessionPurpose,
@@ -265,6 +292,8 @@ export class MockAgentRuntime implements AgentRuntime {
tools,
taskId: options.taskId,
taskTitle: options.taskTitle,
workflowStepId,
workflowStepTemplateId,
invokeTool: (name, args) => executeTool(tools, options, name, args),
});
}

View File

@@ -326,8 +326,10 @@ export type DatabaseMutationType =
| "task:auto-recover-worktree-metadata-skipped-active"
// task:auto-archived-ghost-bug metadata: { findings: Array<{ construct: { kind: string; raw: string; filePath?: string; line?: number }; matched: boolean; probeError?: string; output?: string }>; reason: string }
// task:auto-archived-duplicate metadata: { siblingTaskIds: string[]; scores: Record<string, number> }
// task:broad-scope-flagged-at-triage metadata: { score: number; reasons: string[]; signals: { size: "S"|"M"|"L"|null; stepCount: number; fileScopeCount: number; failingFileMentions: number }; thresholds: { stepsHigh: number; fileScopeHigh: number; failingFileMentionsHigh: number; sizeLStepsThreshold: number }; version: number }
| "task:auto-archived-ghost-bug"
| "task:auto-archived-duplicate"
| "task:broad-scope-flagged-at-triage"
| "task:auto-reconciled-self-defeating-dep"
| "task:dependency-cycle-rejected"
| "task:dependency-cycle-detected"

View File

@@ -0,0 +1,90 @@
export const BROAD_SCOPE_FLAG_VERSION = 1;
export const DEFAULT_BROAD_SCOPE_THRESHOLDS = {
stepsHigh: 12,
fileScopeHigh: 20,
failingFileMentionsHigh: 30,
sizeLStepsThreshold: 9,
} as const;
export interface BroadScopeSignals {
size: "S" | "M" | "L" | null;
stepCount: number;
fileScopeCount: number;
failingFileMentions: number;
}
export interface BroadScopeFlagDecision {
flagged: boolean;
score: number;
reasons: string[];
signals: BroadScopeSignals;
thresholds: typeof DEFAULT_BROAD_SCOPE_THRESHOLDS;
version: number;
}
export function extractBroadScopeSignals(input: {
size: "S" | "M" | "L" | null;
stepCount: number;
fileScopeCount: number;
descriptionText: string;
}): BroadScopeSignals {
const matches = input.descriptionText.matchAll(/\b(\d{2,})\s+(failing|broken|test|file)s?\b/gi);
let failingFileMentions = 0;
for (const match of matches) {
const value = Number.parseInt(match[1] ?? "0", 10);
if (Number.isFinite(value)) {
failingFileMentions = Math.max(failingFileMentions, Math.min(value, 9999));
}
}
return {
size: input.size,
stepCount: input.stepCount,
fileScopeCount: input.fileScopeCount,
failingFileMentions,
};
}
export function decideBroadScopeFlag(
signals: BroadScopeSignals,
thresholds: Partial<typeof DEFAULT_BROAD_SCOPE_THRESHOLDS> = {},
): BroadScopeFlagDecision {
const resolvedThresholds = {
...DEFAULT_BROAD_SCOPE_THRESHOLDS,
...thresholds,
};
const reasons: string[] = [];
let score = 0;
if (signals.size === "L") {
score += 2;
reasons.push("size-l");
}
if (signals.stepCount >= resolvedThresholds.stepsHigh) {
score += 2;
reasons.push("steps-high");
}
if (signals.fileScopeCount >= resolvedThresholds.fileScopeHigh) {
score += 2;
reasons.push("file-scope-high");
}
if (signals.failingFileMentions >= resolvedThresholds.failingFileMentionsHigh) {
score += 2;
reasons.push("failing-file-mentions-high");
}
if (signals.size === "L" && signals.stepCount >= resolvedThresholds.sizeLStepsThreshold) {
score += 1;
reasons.push("size-l-with-many-steps");
}
return {
flagged: score >= 3,
score,
reasons,
signals,
thresholds: resolvedThresholds,
version: BROAD_SCOPE_FLAG_VERSION,
};
}

View File

@@ -79,6 +79,11 @@ import {
isResearchToolSurfaceEnabled,
} from "./tool-availability.js";
import { runGhostBugPreflight } from "./triage-preflight.js";
import {
BROAD_SCOPE_FLAG_VERSION,
decideBroadScopeFlag,
extractBroadScopeSignals,
} from "./triage-broad-scope-heuristics.js";
import { archiveAsGhostBug } from "./self-healing.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
@@ -2418,6 +2423,54 @@ export class TriageProcessor {
} catch {
// Fail open on persisted PROMPT.md parsing and keep using the in-memory parse.
}
type BroadScopeFlagRecord = {
score: number;
reasons: string[];
signals: {
size: "S" | "M" | "L" | null;
stepCount: number;
fileScopeCount: number;
failingFileMentions: number;
};
thresholds: {
stepsHigh: number;
fileScopeHigh: number;
failingFileMentionsHigh: number;
sizeLStepsThreshold: number;
};
version: number;
flaggedAt: string;
};
let broadScopeFlagRecord: BroadScopeFlagRecord | null = null;
try {
const broadScopeSignals = extractBroadScopeSignals({
size: taskUpdates.size ?? task.size ?? null,
stepCount: parsedSteps.length,
fileScopeCount: parsedFileScope.length,
descriptionText: task.description ?? "",
});
const broadScopeDecision = decideBroadScopeFlag(broadScopeSignals);
if (broadScopeDecision.flagged) {
broadScopeFlagRecord = {
score: broadScopeDecision.score,
reasons: broadScopeDecision.reasons,
signals: broadScopeDecision.signals,
thresholds: broadScopeDecision.thresholds,
version: BROAD_SCOPE_FLAG_VERSION,
flaggedAt: new Date().toISOString(),
};
taskUpdates.sourceMetadataPatch = {
...(taskUpdates.sourceMetadataPatch ?? {}),
broadScopeFlag: broadScopeFlagRecord,
};
planLog.warn(
`${task.id}: broad-scope flag at triage — score=${broadScopeDecision.score}, reasons=${broadScopeDecision.reasons.join(",")}`,
);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
planLog.warn(`${task.id}: broad-scope heuristic failed open: ${message}`);
}
let taskIntentSignature: ReturnType<typeof extractIntentSignature> = {
routePaths: [],
filePaths: [],
@@ -2454,6 +2507,37 @@ export class TriageProcessor {
await this.store.updateTask(task.id, taskUpdates);
if (broadScopeFlagRecord) {
try {
await this.store.logEntry(
task.id,
"Broad-scope triage flag",
`Heuristics suggest this task may benefit from decomposition (score=${broadScopeFlagRecord.score}; signals: ${broadScopeFlagRecord.reasons.join(", ")}). Consider creating child tasks via fn_task_create or marking breakIntoSubtasks=true before execution.`,
);
const auditor = createRunAuditor(this.store, {
taskId: task.id,
agentId: task.assignedAgentId ?? "triage",
runId: generateSyntheticRunId("triage", task.id),
phase: "triage",
source: "triage",
});
await auditor.database({
type: "task:broad-scope-flagged-at-triage",
target: task.id,
metadata: {
score: broadScopeFlagRecord.score,
reasons: broadScopeFlagRecord.reasons,
signals: broadScopeFlagRecord.signals,
thresholds: broadScopeFlagRecord.thresholds,
version: broadScopeFlagRecord.version,
},
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
planLog.warn(`${task.id}: broad-scope heuristic failed open: ${message}`);
}
}
try {
const preflightDecision = await Promise.race([
runGhostBugPreflight(