Deduplicate reviewer policy text by making workflow IR review seams the engine's built-in prompt source. - Move the canonical built-in reviewer prompt into core agent prompts and export seam prompt resolution helpers. - Resolve reviewer prompts from explicit role overrides first, then workflow IR review seams, with the built-in prompt as a fallback. - Update prompt cache and reviewer tests to cover single-source reviewer prompt behavior. - Add a patch changeset for the published Fusion package. Files changed: .../FN-6235-reviewer-prompt-single-source.md | 5 + packages/core/src/__tests__/agent-prompts.test.ts | 8 +- packages/core/src/agent-prompts.ts | 109 +++++++++-- packages/core/src/index.ts | 2 + packages/core/src/workflow-ir-resolver.ts | 31 ++- .../src/__tests__/prompt-cache-integration.test.ts | 10 +- .../reviewer-prompt-single-source.test.ts | 160 +++++++++++++++ packages/engine/src/__tests__/reviewer.test.ts | 97 ++++----- packages/engine/src/prompt-layers.ts | 2 +- packages/engine/src/reviewer.ts | 217 ++------------------- 10 files changed, 370 insertions(+), 271 deletions(-) Fusion-Task-Id: FN-6235 Fusion-Task-Lineage: 725932d1-2469-4507-a0f9-0946c83a1572
833 lines
32 KiB
TypeScript
833 lines
32 KiB
TypeScript
// port-4040-allowlist: reviewer prompts resolve from @fusion/core agent-prompts, which embeds the "never kill port 4040" rule.
|
|
/**
|
|
* Reviewer — spawns a separate pi agent to review a worker's plan or code.
|
|
*
|
|
* Replicates taskplane's cross-model review pattern:
|
|
* - Worker calls fn_review_step(step, type) during execution
|
|
* - A separate reviewer agent is spawned with read-only tools
|
|
* - Reviewer writes a structured verdict: APPROVE, REVISE, or RETHINK
|
|
* - Verdict + feedback is returned to the worker
|
|
*/
|
|
|
|
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
|
|
import {
|
|
buildReviewerMemoryInstructions,
|
|
resolveAgentMemoryInclusionMode,
|
|
resolveAgentPrompt,
|
|
resolvePersistAgentThinkingLog,
|
|
resolveTaskSeamPrompt,
|
|
} from "@fusion/core";
|
|
import { recordRetry } from "./retry-burned-logger.js";
|
|
import { mergeEffectiveSettings } from "./effective-settings.js";
|
|
import { describeModel, promptWithFallback } from "./pi.js";
|
|
import { isContextLimitError } from "./context-limit-detector.js";
|
|
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
|
|
import { buildSessionSkillContext } from "./session-skill-context.js";
|
|
import { AgentLogger } from "./agent-logger.js";
|
|
import { reviewerLog } from "./logger.js";
|
|
import { checkSessionError } from "./usage-limit-detector.js";
|
|
import {
|
|
resolveAgentInstructions,
|
|
buildPluginPromptSection,
|
|
} from "./agent-instructions.js";
|
|
import { buildPromptLayers, collapsePromptLayers } from "./prompt-layers.js";
|
|
import { createFallbackModelObserver } from "./fallback-model-observer.js";
|
|
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
|
import { createMemoryGetTool, createMemorySearchTool, createWebFetchTool } from "./agent-tools.js";
|
|
|
|
export type ReviewType = "plan" | "code" | "spec";
|
|
export type ReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
|
|
|
export interface ReviewResult {
|
|
verdict: ReviewVerdict;
|
|
review: string;
|
|
summary: string;
|
|
}
|
|
|
|
export interface ReviewOptions {
|
|
onText?: (delta: string) => void;
|
|
/** Default model provider (e.g. "anthropic"). When set with `defaultModelId`, overrides the reviewer's model selection. */
|
|
defaultProvider?: string;
|
|
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). When set with `defaultProvider`, overrides the reviewer's model selection. */
|
|
defaultModelId?: string;
|
|
/** Task-level validator model provider override. When both provider and modelId are set, takes precedence over project/global lanes. */
|
|
taskValidatorProvider?: string;
|
|
/** Task-level validator model ID override. When both provider and modelId are set, takes precedence over project/global lanes. */
|
|
taskValidatorModelId?: string;
|
|
/** Project-level validator model provider override. Takes precedence over global validator lane. */
|
|
projectValidatorProvider?: string;
|
|
/** Project-level validator model ID override. Takes precedence over global validator lane. */
|
|
projectValidatorModelId?: string;
|
|
/** Global validator lane provider. Takes precedence over project default override + execution defaults. */
|
|
globalValidatorProvider?: string;
|
|
/** Global validator lane model ID. Takes precedence over project default override + execution defaults. */
|
|
globalValidatorModelId?: string;
|
|
/** Project-level default provider override, used when validator lanes are absent. */
|
|
projectDefaultOverrideProvider?: string;
|
|
/** Project-level default model override, used when validator lanes are absent. */
|
|
projectDefaultOverrideModelId?: string;
|
|
/** Fallback model provider used when the primary reviewer model hits a retryable provider-side error. */
|
|
fallbackProvider?: string;
|
|
/** Fallback model ID used with `fallbackProvider`. */
|
|
fallbackModelId?: string;
|
|
/** Project-level validator fallback provider override. Takes precedence over global fallback. */
|
|
projectValidatorFallbackProvider?: string;
|
|
/** Project-level validator fallback model ID override. Takes precedence over global fallback. */
|
|
projectValidatorFallbackModelId?: string;
|
|
/** Default thinking effort level for the reviewer agent session. */
|
|
defaultThinkingLevel?: string;
|
|
/** Task store for persisting agent log entries. When provided with `taskId`, enables full conversation logging. */
|
|
store?: TaskStore;
|
|
/** Task ID for agent log persistence. Required alongside `store`. */
|
|
taskId?: string;
|
|
/** Optional reviewer agent id for retry-burn telemetry. */
|
|
agentId?: string;
|
|
/** Optional task title for fallback-used notification context. */
|
|
taskTitle?: string;
|
|
/** Task with optional assignedAgentId for skill selection. */
|
|
task?: { assignedAgentId?: string | null };
|
|
/** User comments on the task (author === "user"). For spec reviews, the reviewer explicitly checks that every comment is addressed. */
|
|
userComments?: TaskComment[];
|
|
/** Agent prompt configuration for resolving custom reviewer prompts. */
|
|
agentPrompts?: AgentPromptsConfig;
|
|
/** AgentStore for resolving per-agent custom instructions. */
|
|
agentStore?: import("@fusion/core").AgentStore;
|
|
/** Project root directory for resolving relative instructionsPath files. */
|
|
rootDir?: string;
|
|
/** Project settings used for backend-aware memory tools and instructions. */
|
|
settings?: Settings;
|
|
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
|
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
|
/**
|
|
* Fired immediately after the reviewer's `AgentSession` is created. The
|
|
* caller can register the session in a per-task subagent map so that the
|
|
* session can be disposed when the parent task moves out of `in-progress`,
|
|
* is paused, or the engine globally pauses. Without this hook, reviewer
|
|
* sessions outlive their parent task on a stop signal.
|
|
*/
|
|
onSessionCreated?: (session: import("@earendil-works/pi-coding-agent").AgentSession) => void;
|
|
/**
|
|
* Fired in a `finally` block after the reviewer is fully done (or aborted).
|
|
* Pair with `onSessionCreated` to deregister from the subagent map.
|
|
*/
|
|
onSessionEnded?: (session: import("@earendil-works/pi-coding-agent").AgentSession) => void;
|
|
}
|
|
|
|
/**
|
|
* Spawn a reviewer agent to evaluate a worker's plan or code for a step.
|
|
*/
|
|
export async function reviewStep(
|
|
cwd: string,
|
|
taskId: string,
|
|
stepNumber: number,
|
|
stepName: string,
|
|
reviewType: ReviewType,
|
|
promptContent: string,
|
|
baseline?: string,
|
|
options: ReviewOptions = {},
|
|
): Promise<ReviewResult> {
|
|
// Pause gate: do not spawn a reviewer subprocess while the engine is paused.
|
|
// Re-read settings from the store so a stale `options.settings` snapshot can't
|
|
// leak a reviewer past a pause that flipped on after the parent agent started.
|
|
let liveSettings: Settings | undefined = options.settings;
|
|
if (options.store) {
|
|
try {
|
|
liveSettings = await options.store.getSettings();
|
|
} catch {
|
|
// Fall back to the snapshot — better to spawn than crash on a transient store error.
|
|
}
|
|
}
|
|
if (liveSettings?.globalPause || liveSettings?.enginePaused) {
|
|
const reason = liveSettings.globalPause ? "Global pause" : "Engine paused";
|
|
reviewerLog.log(
|
|
`${taskId}: ${reviewType} review for Step ${stepNumber} skipped — ${reason} active`,
|
|
);
|
|
if (options.store && options.taskId) {
|
|
try {
|
|
await options.store.logEntry(
|
|
options.taskId,
|
|
`${reviewType} review skipped — ${reason} active`,
|
|
);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
return {
|
|
verdict: "UNAVAILABLE",
|
|
review: `${reason} active — reviewer not spawned. Stop calling fn_review_* and exit cleanly; the parent task will resume after unpause.`,
|
|
summary: `Skipped: ${reason}`,
|
|
};
|
|
}
|
|
|
|
const request = buildReviewRequest(
|
|
taskId, stepNumber, stepName, reviewType, promptContent, cwd, baseline, options.userComments,
|
|
);
|
|
|
|
const agentLogger = options.store && options.taskId
|
|
? new AgentLogger({
|
|
store: options.store,
|
|
taskId: options.taskId,
|
|
agent: "reviewer",
|
|
onAgentText: options.onText
|
|
? (_id, delta) => options.onText!(delta)
|
|
: undefined,
|
|
persistAgentToolOutput: liveSettings?.persistAgentToolOutput,
|
|
// Reviewer sessions are task-scoped ephemeral workers.
|
|
persistAgentThinkingLog: resolvePersistAgentThinkingLog(liveSettings, { ephemeral: true }),
|
|
})
|
|
: null;
|
|
|
|
const validatorProvider = options.taskValidatorProvider && options.taskValidatorModelId
|
|
? options.taskValidatorProvider
|
|
: (options.projectValidatorProvider && options.projectValidatorModelId
|
|
? options.projectValidatorProvider
|
|
: (options.globalValidatorProvider && options.globalValidatorModelId
|
|
? options.globalValidatorProvider
|
|
: (options.projectDefaultOverrideProvider && options.projectDefaultOverrideModelId
|
|
? options.projectDefaultOverrideProvider
|
|
: options.defaultProvider)));
|
|
const validatorModelId = options.taskValidatorProvider && options.taskValidatorModelId
|
|
? options.taskValidatorModelId
|
|
: (options.projectValidatorProvider && options.projectValidatorModelId
|
|
? options.projectValidatorModelId
|
|
: (options.globalValidatorProvider && options.globalValidatorModelId
|
|
? options.globalValidatorModelId
|
|
: (options.projectDefaultOverrideProvider && options.projectDefaultOverrideModelId
|
|
? options.projectDefaultOverrideModelId
|
|
: options.defaultModelId)));
|
|
|
|
const validatorFallbackProvider = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
|
|
? options.projectValidatorFallbackProvider
|
|
: options.fallbackProvider;
|
|
const validatorFallbackModelId = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
|
|
? options.projectValidatorFallbackModelId
|
|
: options.fallbackModelId;
|
|
|
|
let reviewerInstructions = "";
|
|
if (options.agentStore && options.rootDir) {
|
|
try {
|
|
const agents = await options.agentStore.listAgents({ role: "reviewer" });
|
|
for (const agent of agents) {
|
|
if (agent.instructionsText || agent.instructionsPath) {
|
|
const memoryMode = resolveAgentMemoryInclusionMode({ agent, globalSettings: options.settings }).mode;
|
|
reviewerInstructions = await resolveAgentInstructions(agent, options.rootDir, undefined, memoryMode);
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
// Graceful fallback
|
|
}
|
|
}
|
|
const userReviewerPrompt = options.agentPrompts?.roleAssignments?.reviewer
|
|
? resolveAgentPrompt("reviewer", options.agentPrompts)
|
|
: "";
|
|
const workflowReviewerPrompt = options.store
|
|
? await resolveTaskSeamPrompt(options.store, taskId, "review").catch(() => undefined)
|
|
: undefined;
|
|
// FN-6235: built-in reviewer policy is sourced from the resolved workflow IR review node;
|
|
// explicit reviewer role overrides still win, and the built-in default keeps this fail-soft.
|
|
const reviewerBasePrompt = userReviewerPrompt || workflowReviewerPrompt || resolveAgentPrompt("reviewer");
|
|
const memorySection = options.rootDir && options.settings?.memoryEnabled !== false
|
|
? buildReviewerMemoryInstructions(options.rootDir, options.settings)
|
|
: "";
|
|
|
|
const reviewerPluginContributions = buildPluginPromptSection(
|
|
"reviewer",
|
|
options.pluginRunner,
|
|
);
|
|
if (reviewerPluginContributions) {
|
|
reviewerLog.log(`applied plugin prompt contributions for reviewer surface`);
|
|
}
|
|
|
|
const layers = buildPromptLayers({
|
|
basePrompt: reviewerBasePrompt,
|
|
agentInstructions: reviewerInstructions,
|
|
memorySection,
|
|
pluginContributions: reviewerPluginContributions,
|
|
});
|
|
const reviewerSystemPromptFinal = collapsePromptLayers(layers);
|
|
|
|
let skillContext = undefined;
|
|
if (options.agentStore && options.rootDir) {
|
|
try {
|
|
skillContext = await buildSessionSkillContext({
|
|
agentStore: options.agentStore,
|
|
task: options.task ?? {},
|
|
sessionPurpose: "reviewer",
|
|
projectRootDir: options.rootDir,
|
|
pluginRunner: options.pluginRunner,
|
|
});
|
|
} catch {
|
|
// Graceful fallback - no skill selection
|
|
}
|
|
}
|
|
|
|
const assignedAgentId = options.task?.assignedAgentId ?? null;
|
|
const agentStore = options.agentStore;
|
|
const memoryAgent =
|
|
options.rootDir
|
|
&& agentStore
|
|
&& assignedAgentId
|
|
&& typeof (agentStore as { getAgent?: unknown }).getAgent === "function"
|
|
? await agentStore.getAgent(assignedAgentId).catch(() => null)
|
|
: null;
|
|
const memoryTools = options.rootDir && options.settings?.memoryEnabled !== false
|
|
? [
|
|
createMemorySearchTool(options.rootDir, options.settings, memoryAgent ? {
|
|
agentMemory: {
|
|
agentId: memoryAgent.id,
|
|
agentName: memoryAgent.name,
|
|
memory: memoryAgent.memory,
|
|
},
|
|
} : undefined),
|
|
createMemoryGetTool(options.rootDir, options.settings, memoryAgent ? {
|
|
agentMemory: {
|
|
agentId: memoryAgent.id,
|
|
agentName: memoryAgent.name,
|
|
memory: memoryAgent.memory,
|
|
},
|
|
} : undefined),
|
|
]
|
|
: undefined;
|
|
class ReviewerPauseAbortError extends Error {
|
|
constructor(public readonly reason: string) {
|
|
super(`reviewer aborted: ${reason}`);
|
|
}
|
|
}
|
|
|
|
const activeSessions = new Set<import("@earendil-works/pi-coding-agent").AgentSession>();
|
|
let reviewText = "";
|
|
|
|
const endSession = (session: import("@earendil-works/pi-coding-agent").AgentSession) => {
|
|
if (!activeSessions.delete(session)) {
|
|
return;
|
|
}
|
|
session.dispose();
|
|
options.onSessionEnded?.(session);
|
|
};
|
|
|
|
const buildPauseUnavailableResult = async (reason: string): Promise<ReviewResult> => {
|
|
reviewerLog.log(
|
|
`${taskId}: ${reviewType} review for Step ${stepNumber} aborted before spawn — ${reason} active`,
|
|
);
|
|
if (options.store && options.taskId) {
|
|
await options.store.logEntry(
|
|
options.taskId,
|
|
`${reviewType} review aborted before spawn — ${reason} active`,
|
|
).catch(() => undefined);
|
|
}
|
|
return {
|
|
verdict: "UNAVAILABLE",
|
|
review: `${reason} active — reviewer not spawned. Stop calling fn_review_* and exit cleanly; the parent task will resume after unpause.`,
|
|
summary: `Skipped: ${reason}`,
|
|
};
|
|
};
|
|
|
|
const createReviewerSession = async (
|
|
overrides?: { forceProvider?: string; forceModelId?: string },
|
|
): Promise<import("@earendil-works/pi-coding-agent").AgentSession> => {
|
|
const runAuditor = options.store
|
|
? createRunAuditor(options.store, {
|
|
runId: generateSyntheticRunId("reviewer", options.taskId ?? "review"),
|
|
agentId: options.agentId ?? "reviewer",
|
|
taskId: options.taskId,
|
|
phase: "review",
|
|
source: "reviewer",
|
|
})
|
|
: undefined;
|
|
const { session } = await createResolvedAgentSession({
|
|
sessionPurpose: "reviewer",
|
|
runtimeHint: extractRuntimeHint(memoryAgent?.runtimeConfig),
|
|
pluginRunner: options.pluginRunner,
|
|
cwd,
|
|
systemPrompt: reviewerSystemPromptFinal,
|
|
systemPromptLayers: layers,
|
|
tools: "readonly",
|
|
customTools: [createWebFetchTool(), ...(memoryTools ?? [])],
|
|
onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
|
|
onThinking: agentLogger?.onThinking,
|
|
onToolStart: agentLogger?.onToolStart,
|
|
onToolEnd: agentLogger?.onToolEnd,
|
|
defaultProvider: overrides?.forceProvider ?? validatorProvider,
|
|
defaultModelId: overrides?.forceModelId ?? validatorModelId,
|
|
fallbackProvider: validatorFallbackProvider,
|
|
fallbackModelId: validatorFallbackModelId,
|
|
defaultThinkingLevel: options.defaultThinkingLevel,
|
|
runAuditor,
|
|
settings: options.settings,
|
|
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
|
taskId: options.taskId,
|
|
taskTitle: options.taskTitle,
|
|
onFallbackModelUsed: createFallbackModelObserver({
|
|
agent: "reviewer",
|
|
label: "reviewer",
|
|
store: options.store,
|
|
taskId: options.taskId,
|
|
taskTitle: options.taskTitle,
|
|
}),
|
|
beforeSpawnSession: async () => {
|
|
if (!options.store) return;
|
|
let finalSettings: Settings | undefined;
|
|
try {
|
|
finalSettings = await options.store.getSettings();
|
|
} catch {
|
|
return;
|
|
}
|
|
if (finalSettings?.globalPause || finalSettings?.enginePaused) {
|
|
const reason = finalSettings.globalPause ? "Global pause" : "Engine paused";
|
|
throw new ReviewerPauseAbortError(reason);
|
|
}
|
|
},
|
|
});
|
|
|
|
const reviewerModelDesc = describeModel(session);
|
|
const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`;
|
|
reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDesc}`);
|
|
if (options.store && options.taskId) {
|
|
await options.store.logEntry(options.taskId, reviewerModelMarker);
|
|
await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined);
|
|
}
|
|
|
|
activeSessions.add(session);
|
|
options.onSessionCreated?.(session);
|
|
session.subscribe((event) => {
|
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
reviewText += event.assistantMessageEvent.delta;
|
|
}
|
|
});
|
|
|
|
return session;
|
|
};
|
|
|
|
const runReviewPrompt = async (
|
|
session: import("@earendil-works/pi-coding-agent").AgentSession,
|
|
prompt: string,
|
|
): Promise<void> => {
|
|
await promptWithFallback(session, prompt);
|
|
checkSessionError(session);
|
|
};
|
|
|
|
const runAttempt = async (
|
|
attemptRequest: string,
|
|
sessionOptions?: { forceProvider?: string; forceModelId?: string },
|
|
): Promise<{ verdict: ReviewVerdict; summary: string; review: string }> => {
|
|
reviewText = "";
|
|
let session: import("@earendil-works/pi-coding-agent").AgentSession;
|
|
try {
|
|
session = await createReviewerSession(sessionOptions);
|
|
} catch (err) {
|
|
if (err instanceof ReviewerPauseAbortError) {
|
|
return buildPauseUnavailableResult(err.reason);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
try {
|
|
await runReviewPrompt(session, attemptRequest);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
if (!isContextLimitError(errorMessage)) {
|
|
throw err;
|
|
}
|
|
|
|
const retryLogMessage = reviewType === "code"
|
|
? "code review hit context limit — retrying with compacted request"
|
|
: `${reviewType} review hit context limit — retrying with compacted request`;
|
|
reviewerLog.warn(`${taskId}: ${retryLogMessage}`);
|
|
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
|
await options.store.logEntry(options.taskId, retryLogMessage).catch(() => undefined);
|
|
const taskForRetry = await options.store.getTask(options.taskId);
|
|
await recordRetry({
|
|
store: options.store,
|
|
settings: retrySettings,
|
|
task: taskForRetry,
|
|
category: "reviewerContext",
|
|
role: "reviewer",
|
|
agentId: options.agentId,
|
|
});
|
|
}
|
|
|
|
reviewText = "";
|
|
const reducedRequest = buildReducedReviewRequest(
|
|
taskId, stepNumber, stepName, reviewType, promptContent, cwd, baseline,
|
|
);
|
|
|
|
try {
|
|
await runReviewPrompt(session, reducedRequest);
|
|
} catch (retryErr: unknown) {
|
|
if (!isReviewerSessionReuseError(retryErr)) {
|
|
throw retryErr;
|
|
}
|
|
|
|
endSession(session);
|
|
try {
|
|
session = await createReviewerSession(sessionOptions);
|
|
} catch (recreateErr) {
|
|
if (recreateErr instanceof ReviewerPauseAbortError) {
|
|
return buildPauseUnavailableResult(recreateErr.reason);
|
|
}
|
|
throw recreateErr;
|
|
}
|
|
await runReviewPrompt(session, reducedRequest);
|
|
}
|
|
}
|
|
} finally {
|
|
if (agentLogger) {
|
|
await agentLogger.flush();
|
|
}
|
|
for (const activeSession of [...activeSessions]) {
|
|
endSession(activeSession);
|
|
}
|
|
}
|
|
|
|
const verdict = extractVerdict(reviewText);
|
|
const summary = extractSummary(reviewText);
|
|
return { verdict, review: reviewText, summary };
|
|
};
|
|
|
|
const fallbackReviewRequest = `${request}\n\nIMPORTANT: Respond with exactly one of: APPROVE | REVISE | RETHINK on a line starting with "Verdict:".`;
|
|
|
|
const logFallbackRetry = async (reason: string, mode: string): Promise<void> => {
|
|
const message = `${reviewType} review retry with fallback model after ${reason} (${mode})`;
|
|
reviewerLog.warn(`${taskId}: ${message}`);
|
|
if (options.store && options.taskId) {
|
|
await options.store.logEntry(options.taskId, message).catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
const hasConfiguredFallback = Boolean(validatorFallbackProvider && validatorFallbackModelId);
|
|
// Merge per-task effective workflow settings (U3, KTD-3) over the base so the
|
|
// retry-budget reads (maxReviewerContextRetries / maxReviewerFallbackRetries via
|
|
// recordRetry) pick up workflow values. `liveSettings`/`options.settings` are the
|
|
// base; the merge is behavior-inert when nothing is customized. Resolved once
|
|
// here (all recordRetry sites share it).
|
|
const retrySettingsBase = liveSettings ?? options.settings;
|
|
let retrySettings = retrySettingsBase;
|
|
if (options.store && options.taskId && retrySettingsBase) {
|
|
try {
|
|
const retryTask = await options.store.getTask(options.taskId);
|
|
retrySettings = await mergeEffectiveSettings(options.store, retryTask, retrySettingsBase);
|
|
} catch {
|
|
// Keep the base snapshot on any store/resolve error (never-throw).
|
|
}
|
|
}
|
|
|
|
const resetReviewerFallbackRetryCount = async (): Promise<void> => {
|
|
if (!options.store || !options.taskId || typeof options.store.updateTask !== "function") {
|
|
return;
|
|
}
|
|
await options.store.updateTask(options.taskId, { reviewerFallbackRetryCount: 0 }).catch(() => undefined);
|
|
};
|
|
|
|
let firstAttempt: { verdict: ReviewVerdict; summary: string; review: string };
|
|
try {
|
|
firstAttempt = await runAttempt(request);
|
|
} catch (err) {
|
|
if (hasConfiguredFallback) {
|
|
await logFallbackRetry("reviewer error", `${validatorFallbackProvider}/${validatorFallbackModelId}`);
|
|
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
|
const taskForRetry = await options.store.getTask(options.taskId);
|
|
await recordRetry({
|
|
store: options.store,
|
|
settings: retrySettings,
|
|
task: taskForRetry,
|
|
category: "reviewerFallback",
|
|
role: "reviewer",
|
|
agentId: options.agentId,
|
|
});
|
|
}
|
|
try {
|
|
const fallbackResult = await runAttempt(request, {
|
|
forceProvider: validatorFallbackProvider,
|
|
forceModelId: validatorFallbackModelId,
|
|
});
|
|
if (fallbackResult.verdict !== "UNAVAILABLE") {
|
|
await resetReviewerFallbackRetryCount();
|
|
}
|
|
return fallbackResult;
|
|
} catch {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
await logFallbackRetry("reviewer error", "same-model strict prompt");
|
|
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
|
const taskForRetry = await options.store.getTask(options.taskId);
|
|
await recordRetry({
|
|
store: options.store,
|
|
settings: retrySettings,
|
|
task: taskForRetry,
|
|
category: "reviewerFallback",
|
|
role: "reviewer",
|
|
agentId: options.agentId,
|
|
});
|
|
}
|
|
try {
|
|
const fallbackResult = await runAttempt(fallbackReviewRequest);
|
|
if (fallbackResult.verdict !== "UNAVAILABLE") {
|
|
await resetReviewerFallbackRetryCount();
|
|
}
|
|
return fallbackResult;
|
|
} catch {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (firstAttempt.verdict !== "UNAVAILABLE") {
|
|
await resetReviewerFallbackRetryCount();
|
|
return firstAttempt;
|
|
}
|
|
|
|
if (hasConfiguredFallback) {
|
|
await logFallbackRetry("UNAVAILABLE verdict", `${validatorFallbackProvider}/${validatorFallbackModelId}`);
|
|
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
|
const taskForRetry = await options.store.getTask(options.taskId);
|
|
await recordRetry({
|
|
store: options.store,
|
|
settings: retrySettings,
|
|
task: taskForRetry,
|
|
category: "reviewerFallback",
|
|
role: "reviewer",
|
|
agentId: options.agentId,
|
|
});
|
|
}
|
|
const fallbackResult = await runAttempt(request, {
|
|
forceProvider: validatorFallbackProvider,
|
|
forceModelId: validatorFallbackModelId,
|
|
});
|
|
if (fallbackResult.verdict !== "UNAVAILABLE") {
|
|
await resetReviewerFallbackRetryCount();
|
|
}
|
|
return fallbackResult;
|
|
}
|
|
|
|
await logFallbackRetry("UNAVAILABLE verdict", "same-model strict prompt");
|
|
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
|
const taskForRetry = await options.store.getTask(options.taskId);
|
|
await recordRetry({
|
|
store: options.store,
|
|
settings: retrySettings,
|
|
task: taskForRetry,
|
|
category: "reviewerFallback",
|
|
role: "reviewer",
|
|
agentId: options.agentId,
|
|
});
|
|
}
|
|
const fallbackResult = await runAttempt(fallbackReviewRequest);
|
|
if (fallbackResult.verdict !== "UNAVAILABLE") {
|
|
await resetReviewerFallbackRetryCount();
|
|
}
|
|
return fallbackResult;
|
|
}
|
|
|
|
function isReviewerSessionReuseError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return /prompt is in progress|session (?:is )?(?:closed|disposed|ended)|conversation already active/i.test(message);
|
|
}
|
|
|
|
function extractPromptSection(promptContent: string, sectionName: string): string {
|
|
const heading = `## ${sectionName}`;
|
|
const start = promptContent.indexOf(heading);
|
|
if (start === -1) {
|
|
return "";
|
|
}
|
|
|
|
const afterHeading = start + heading.length;
|
|
const nextH2 = promptContent.indexOf("\n## ", afterHeading);
|
|
const nextH1 = promptContent.indexOf("\n# ", afterHeading);
|
|
const endCandidates = [nextH2, nextH1].filter((value) => value !== -1);
|
|
const end = endCandidates.length > 0 ? Math.min(...endCandidates) : promptContent.length;
|
|
return promptContent.slice(start, end).trim();
|
|
}
|
|
|
|
function summarizePromptSteps(promptContent: string): string {
|
|
const stepTitles = Array.from(promptContent.matchAll(/^### Step \d+:.*$/gm), (match) => match[0].trim());
|
|
if (stepTitles.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
return ["## Steps", ...stepTitles].join("\n");
|
|
}
|
|
|
|
function buildReducedTaskPromptSummary(promptContent: string): string {
|
|
const firstSectionIndex = promptContent.indexOf("\n## ");
|
|
const header = (firstSectionIndex === -1 ? promptContent : promptContent.slice(0, firstSectionIndex)).trim();
|
|
const sections = [
|
|
header,
|
|
extractPromptSection(promptContent, "Mission"),
|
|
extractPromptSection(promptContent, "Dependencies"),
|
|
extractPromptSection(promptContent, "File Scope"),
|
|
summarizePromptSteps(promptContent),
|
|
"_... additional PROMPT.md sections omitted after context-limit retry ..._",
|
|
].filter(Boolean);
|
|
|
|
return sections.join("\n\n").trim();
|
|
}
|
|
|
|
function buildReducedReviewRequest(
|
|
taskId: string,
|
|
stepNumber: number,
|
|
stepName: string,
|
|
reviewType: ReviewType,
|
|
promptContent: string,
|
|
cwd: string,
|
|
baseline?: string,
|
|
): string {
|
|
return buildReviewRequest(
|
|
taskId,
|
|
stepNumber,
|
|
stepName,
|
|
reviewType,
|
|
buildReducedTaskPromptSummary(promptContent),
|
|
cwd,
|
|
baseline,
|
|
undefined,
|
|
);
|
|
}
|
|
|
|
function buildReviewRequest(
|
|
taskId: string,
|
|
stepNumber: number,
|
|
stepName: string,
|
|
reviewType: ReviewType,
|
|
promptContent: string,
|
|
cwd: string,
|
|
baseline?: string,
|
|
userComments?: TaskComment[],
|
|
): string {
|
|
const parts = [
|
|
`Review request for task ${taskId}, Step ${stepNumber}: ${stepName}`,
|
|
`Review type: **${reviewType}**`,
|
|
"",
|
|
"## Task PROMPT.md",
|
|
"```markdown",
|
|
promptContent,
|
|
"```",
|
|
"",
|
|
];
|
|
|
|
if (reviewType === "spec") {
|
|
parts.push(
|
|
"## What to review",
|
|
"Evaluate this PROMPT.md specification for completeness and quality.",
|
|
"Assess against the spec quality criteria: mission clarity, step specificity/verifiability,",
|
|
"file scope accuracy, dependency correctness, testing requirements, documentation completeness,",
|
|
"dangling task-document references, and appropriate sizing/review level.",
|
|
"For tasks integrating third-party tools, also verify canonical upstream repo URL, docs URL, release/download URL, binary/CLI name, and checksum or explicit upstream-pending-verification marker are present.",
|
|
"",
|
|
"Read relevant source files to verify the spec references real files, functions, and patterns.",
|
|
"Check that steps have concrete, verifiable outcomes — not vague instructions.",
|
|
"Ensure testing requirements demand real automated tests with assertions.",
|
|
);
|
|
|
|
// Add user comment coverage check for spec reviews
|
|
if (userComments && userComments.length > 0) {
|
|
parts.push(
|
|
"",
|
|
"## User Comment Coverage (MANDATORY)",
|
|
"",
|
|
"The following user comments were posted on this task. You MUST verify that the spec addresses **every** comment. If any user comment is not reflected or addressed in the PROMPT.md, issue a REVISE verdict.",
|
|
"",
|
|
);
|
|
for (const comment of userComments) {
|
|
const date = comment.updatedAt || comment.createdAt;
|
|
parts.push(`- **[${date}]** ${comment.text}`);
|
|
}
|
|
parts.push(
|
|
"",
|
|
"Check each comment above against the spec content. Missing coverage for any user comment is a blocking issue.",
|
|
);
|
|
}
|
|
} else if (reviewType === "plan") {
|
|
parts.push(
|
|
"## What to review",
|
|
`The worker is about to implement Step ${stepNumber} (${stepName}).`,
|
|
"Assess whether the step's checkboxes will achieve the stated outcomes.",
|
|
"Read relevant source files to understand the current codebase state.",
|
|
"Check for risks, missing edge cases, and gaps in the plan.",
|
|
);
|
|
} else {
|
|
parts.push(
|
|
"## What to review",
|
|
`The worker has implemented Step ${stepNumber} (${stepName}).`,
|
|
"Review the code changes for correctness, patterns, and test coverage.",
|
|
"",
|
|
"## Worktree Boundary",
|
|
`Assigned task worktree: \`${cwd}\``,
|
|
"Verify that implementation changes are in this worktree. If you find changes or commits in the primary project checkout or any other path, issue REVISE unless the outside path is an expected project-root exception such as .fusion/memory/ files, task attachments, or explicitly documented Fusion metadata.",
|
|
"",
|
|
);
|
|
if (baseline) {
|
|
parts.push(
|
|
"To see the changes for this step, run:",
|
|
`\`\`\`bash`,
|
|
`git diff ${baseline}..HEAD`,
|
|
`\`\`\``,
|
|
);
|
|
} else {
|
|
parts.push(
|
|
"To see recent changes, run:",
|
|
"```bash",
|
|
"git diff HEAD~1",
|
|
"```",
|
|
);
|
|
}
|
|
}
|
|
|
|
parts.push(
|
|
"",
|
|
"## Instructions",
|
|
"1. Read the relevant source files",
|
|
"2. Assess the work against the task requirements",
|
|
"3. Output your review using the format from your system prompt",
|
|
"4. Be specific with file paths and line numbers",
|
|
);
|
|
|
|
return parts.join("\n");
|
|
}
|
|
|
|
function extractVerdict(review: string): ReviewVerdict {
|
|
// Strategy 1: Look for a JSON verdict block (structured output)
|
|
// Matches: ```json\n{"verdict": "APPROVE"}\n``` or inline {"verdict":"REVISE"}
|
|
const jsonMatch = review.match(
|
|
/\{\s*"verdict"\s*:\s*"(APPROVE|REVISE|RETHINK)"\s*\}/i,
|
|
);
|
|
if (jsonMatch) {
|
|
reviewerLog.log(`Verdict extracted via JSON block: ${jsonMatch[1].toUpperCase()}`);
|
|
return jsonMatch[1].toUpperCase() as ReviewVerdict;
|
|
}
|
|
|
|
// Strategy 2: Look for verdict in a heading line (### Verdict: APPROVE, **Verdict: REVISE**)
|
|
// Only match lines that START with a verdict pattern to avoid matching keywords in body text
|
|
const headingMatch = review.match(
|
|
/^[>\s]*(?:###?\s*|[*_]{1,2})Verdict[:\s]*[*_]{0,2}\s*(APPROVE|REVISE|RETHINK)\b/im,
|
|
);
|
|
if (headingMatch) {
|
|
return headingMatch[1].toUpperCase() as ReviewVerdict;
|
|
}
|
|
|
|
// Strategy 3: Standalone verdict line like "Verdict: APPROVE" or "Decision: REVISE"
|
|
const lineFallback = review.match(
|
|
/^[>\s]*(?:verdict|decision)\s*[-:]\s*(APPROVE|REVISE|RETHINK)\b/im,
|
|
);
|
|
if (lineFallback) {
|
|
return lineFallback[1].toUpperCase() as ReviewVerdict;
|
|
}
|
|
|
|
reviewerLog.warn(`Could not extract verdict from review (${review.length} chars). Returning UNAVAILABLE.`);
|
|
return "UNAVAILABLE";
|
|
}
|
|
|
|
function extractSummary(review: string): string {
|
|
const summaryMatch = review.match(
|
|
/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i,
|
|
);
|
|
if (summaryMatch) {
|
|
return summaryMatch[1].trim().slice(0, 500);
|
|
}
|
|
// Fallback: first paragraph
|
|
const lines = review.split("\n").filter((l) => l.trim());
|
|
return lines.slice(0, 3).join(" ").slice(0, 300);
|
|
}
|