A rate-limited reviewer filled a task's Chat tab with 14 identical
"Reviewer using model: ..." markers and no review text, hammering an
already-limited provider.
Root cause: the reviewer was the only AI lane that never classified
provider errors, so a 429 became an UNAVAILABLE verdict. With no
validator fallback configured the fallback ladder re-ran the SAME model
instantly, and fn_review_step answered with "code review remains
blocking; retry once" — bounding the loop with prompt text rather than
code. The tool's catch-all also swallowed the error into tool output, so
withRateLimitRetry, UsageLimitPauser and RetryStormError never fired.
- reviewer: throw ReviewerProviderError for usage-limit/transient errors
instead of laundering them into UNAVAILABLE, and never spend the
fallback budget (which bounds bad reviews) on an outage.
- reviewer: absorb flaky-network blips in-lane via withRetry with
jittered backoff; rate limits still escalate immediately.
- executor: re-raise the fatal after the prompt via
throwDeferredReviewerFatal — pi-agent-core converts tool throws into
tool_error results, so a tool cannot throw out of session.prompt().
- executor: give code review a real MAX_CODE_REVIEW_UNAVAILABLE_RETRIES
counter, mirroring the plan/spec limiter.
- reviewer: dedupe the model marker on text, so same-model retries stay
silent while a genuine model switch still emits.
Also fixes the run-on rendering: AgentLogType gains `status` for complete
engine messages. `text` means "streamed delta" and is re-glued with
join(""), which is why N standalone markers rendered as one string. The
split is at the type, not a separator — a separator would reintroduce the
FN-5787/5789/5803 streamed-spacing regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
7.0 KiB
TypeScript
183 lines
7.0 KiB
TypeScript
import type { Agent, AgentLogEntry, ResolvedModelSelection, Settings, Task, TaskDetail } from "@fusion/core";
|
|
import { resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
|
|
|
export type ModelSelection = ResolvedModelSelection;
|
|
|
|
/*
|
|
FNXC:MergeQueue 2026-07-15-10:40:
|
|
Treat AI-merge reviewing/landing as active so model/resolution surfaces and cards stay in the live-agent visual state while the merger owns the pump.
|
|
*/
|
|
export const ACTIVE_STATUSES = new Set([
|
|
"planning",
|
|
"researching",
|
|
"executing",
|
|
"finalizing",
|
|
"merging",
|
|
"merging-pr",
|
|
"merging-fix",
|
|
"reviewing",
|
|
"landing",
|
|
]);
|
|
|
|
const STRING_OBJECT_TAG = "[object String]";
|
|
|
|
function isStringValue(value: unknown): value is string {
|
|
return Object.prototype.toString.call(value) === STRING_OBJECT_TAG;
|
|
}
|
|
|
|
/*
|
|
FNXC:ModelResolution 2026-06-25-00:00:
|
|
FN-7040 requires the Chat tab, Agent Log header, and Workflow tab Model settings to share one effective model resolver so runtime log markers, active assigned-agent runtime models, task overrides, and settings fallbacks never diverge between task-detail surfaces.
|
|
|
|
FNXC:TaskLogModelThinking 2026-07-01-00:00:
|
|
Runtime "using model" markers may append parenthesized diagnostics such as thinking effort, workflow-step overrides, or fallback reasons. Dashboard model resolution strips those suffix annotations while preserving legacy exact markers so provider icons and effective-model headers continue to resolve from the same row operators read in Activity and Raw Logs.
|
|
*/
|
|
const MODEL_MARKER_PATTERN = /^(Triage|Executor|Reviewer) using model: ([^/\s]+)\/(.+?)(?:\s+\([^)]*\))*$/;
|
|
|
|
/*
|
|
FNXC:TaskLogModelThinking 2026-07-15-11:20:
|
|
Engine lanes now write standalone messages (including the "using model" markers) as `status` rather than `text`, so complete messages are never glued together like streamed deltas. Model resolution must accept BOTH: `status` for markers written after that change, `text` for the rows already persisted in every existing task's log. Dropping `text` here would silently blank the provider icons and effective-model headers on historical tasks.
|
|
*/
|
|
function isEngineMarkerEntryType(type: AgentLogEntry["type"]): boolean {
|
|
return type === "status" || type === "text";
|
|
}
|
|
|
|
export function parseRuntimeModelMarker(text: string, role: "Triage" | "Executor" | "Reviewer"): { provider: string; modelId: string } | null {
|
|
const match = text.match(MODEL_MARKER_PATTERN);
|
|
if (!match || match[1] !== role) return null;
|
|
return { provider: match[2], modelId: match[3] };
|
|
}
|
|
|
|
export function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
|
let result: { provider: string; modelId: string } | null = null;
|
|
entries.forEach((entry) => {
|
|
if (entry.agent !== "executor" || !isEngineMarkerEntryType(entry.type)) return;
|
|
const match = parseRuntimeModelMarker(entry.text, "Executor");
|
|
if (match) {
|
|
result = match;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
|
let result: { provider: string; modelId: string } | null = null;
|
|
entries.forEach((entry) => {
|
|
if (entry.agent !== "reviewer" || !isEngineMarkerEntryType(entry.type)) return;
|
|
const match = parseRuntimeModelMarker(entry.text, "Reviewer");
|
|
if (match) {
|
|
result = match;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
|
|
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
|
|
const model = isStringValue(runtimeConfig?.model) ? runtimeConfig.model.trim() : "";
|
|
if (model) {
|
|
const slashIdx = model.indexOf("/");
|
|
if (slashIdx > 0 && slashIdx < model.length - 1) {
|
|
return {
|
|
provider: model.slice(0, slashIdx),
|
|
modelId: model.slice(slashIdx + 1),
|
|
};
|
|
}
|
|
}
|
|
|
|
const provider = isStringValue(runtimeConfig?.modelProvider) ? runtimeConfig.modelProvider.trim() : "";
|
|
const modelId = isStringValue(runtimeConfig?.modelId) ? runtimeConfig.modelId.trim() : "";
|
|
return {
|
|
provider: provider || undefined,
|
|
modelId: modelId || undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective executor model following the dashboard display resolution order:
|
|
* 1. Runtime executor model from agent log marker
|
|
* 2. Assigned agent runtime model (active runs only)
|
|
* 3. Per-task modelProvider/modelId override
|
|
* 4. Project/global execution lane fallback
|
|
*/
|
|
export function resolveEffectiveExecutor(
|
|
task: Task | TaskDetail,
|
|
logEntries: AgentLogEntry[],
|
|
assignedAgent: Agent | null,
|
|
settings?: Settings,
|
|
): ModelSelection {
|
|
const fromLog = extractExecutorModelFromLog(logEntries);
|
|
if (fromLog) return fromLog;
|
|
|
|
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
|
|
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
|
|
if (assignedModel.provider && assignedModel.modelId) {
|
|
return assignedModel;
|
|
}
|
|
}
|
|
|
|
return resolveTaskExecutionModel(task, settings);
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective validator model following the dashboard display resolution order.
|
|
* Merger display intentionally reuses this reviewer/validator lane in TaskDetailModal.
|
|
*/
|
|
export function resolveEffectiveValidator(
|
|
task: Task | TaskDetail,
|
|
logEntries: AgentLogEntry[],
|
|
assignedAgent: Agent | null,
|
|
settings?: Settings,
|
|
): ModelSelection {
|
|
const fromLog = extractReviewerModelFromLog(logEntries);
|
|
if (fromLog) return fromLog;
|
|
|
|
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
|
|
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
|
|
if (assignedModel.provider && assignedModel.modelId) {
|
|
return assignedModel;
|
|
}
|
|
}
|
|
|
|
return resolveTaskValidatorModel(task, settings);
|
|
}
|
|
|
|
/**
|
|
* Extract planning model from agent log entries.
|
|
* Looks for text entries with agent role "triage" matching the pattern:
|
|
* "Triage using model: <provider>/<modelId>"
|
|
* Returns the latest match, or null if none found.
|
|
*/
|
|
export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
|
|
let result: { provider: string; modelId: string } | null = null;
|
|
entries.forEach((entry) => {
|
|
if (entry.agent !== "triage" || !isEngineMarkerEntryType(entry.type)) return;
|
|
const match = parseRuntimeModelMarker(entry.text, "Triage");
|
|
if (match) {
|
|
result = match;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective planning model following the preserved dashboard order:
|
|
* 1. Per-task planningModelProvider/planningModelId override
|
|
* 2. Runtime triage model from agent log marker
|
|
* 3. Project/global planning lane fallback
|
|
*/
|
|
export function resolveEffectivePlanning(
|
|
task: Task | TaskDetail,
|
|
logEntries: AgentLogEntry[],
|
|
settings?: Settings,
|
|
): ModelSelection {
|
|
if (task.planningModelProvider && task.planningModelId) {
|
|
return { provider: task.planningModelProvider, modelId: task.planningModelId };
|
|
}
|
|
const fromLog = extractPlanningModelFromLog(logEntries);
|
|
if (fromLog) {
|
|
return fromLog;
|
|
}
|
|
return resolveTaskPlanningModel(task, settings);
|
|
}
|