FN-5941: stop missions stalling on incompatible/custom-role agents
Importing a catalog ("company") agent assigns role "custom", which the
scheduler never auto-assigns mission/queue work to. Combined with a
model/provider that rejects the "developer" system role, this surfaced as
an invisible, repeating failure loop (GitHub #1261).
- pi.ts: treat an unsupported message-role rejection as a model-selection
error so a configured fallback model is tried once (single-swap guarded)
before the task is marked failed.
- mission-autopilot.ts: block a mission feature immediately on an
operator-actionable failure instead of burning the retry budget
re-running the same cryptic error.
- mission-routes.ts: preflight mission start — when ephemeral agents are
disabled and no eligible executor exists, fail fast with an actionable
message instead of queueing tasks forever.
- agent import route + AgentImportModal: warn when only custom-role agents
are imported and no executor exists.
- agent-assignment.ts: extract shared listEligibleExecutorAgents helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
.changeset/fn-5941-mission-incompatible-agent-guard.md
Normal file
12
.changeset/fn-5941-mission-incompatible-agent-guard.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Stop missions from silently looping or stalling when agents can't run their tasks (GitHub #1261).
|
||||||
|
|
||||||
|
Importing a catalog ("company") agent assigns it the role `custom`, which the scheduler never auto-assigns mission/queue work to. Combined with a model/provider that rejects the `developer` system role, this surfaced to users as an invisible, repeating failure loop.
|
||||||
|
|
||||||
|
- **Auto-recover from incompatible roles:** an "unsupported message role" provider rejection (e.g. a reasoning model sending the `developer` role to a provider that only accepts `system`/`user`/`assistant`/`tool`) is now treated as a model-selection error, so a configured fallback model is tried once before the task is marked failed. The single-swap guard keeps an incompatible fallback from looping.
|
||||||
|
- **Stop the retry loop:** operator-actionable failures (unsupported role, auth, quota) now block the mission feature immediately with a clear event instead of burning the full retry budget re-running the same cryptic error.
|
||||||
|
- **Preflight mission start:** when ephemeral agents are disabled and no eligible executor agent exists, starting a mission now fails fast with an actionable message instead of queueing tasks forever.
|
||||||
|
- **Warn on import:** importing only `custom`-role agents now surfaces a warning that they won't be auto-assigned mission work unless one is given the `executor` role.
|
||||||
@@ -164,6 +164,32 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-import-result-warnings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-result-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: calc(var(--space-sm) - var(--space-xs) * 0.5);
|
||||||
|
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||||
|
color: var(--color-warning, #b8860b);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
background: color-mix(in srgb, var(--color-warning, #b8860b) 10%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-import-result-warning svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-import-result-divider {
|
.agent-import-result-divider {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface ImportResult {
|
|||||||
skipped: string[];
|
skipped: string[];
|
||||||
errors: Array<{ name: string; error: string }>;
|
errors: Array<{ name: string; error: string }>;
|
||||||
skills?: SkillImportResult;
|
skills?: SkillImportResult;
|
||||||
|
warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectoryAgentInput {
|
interface DirectoryAgentInput {
|
||||||
@@ -845,6 +846,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{importResult.warnings && importResult.warnings.length > 0 && (
|
||||||
|
<div className="agent-import-result-warnings">
|
||||||
|
{importResult.warnings.map((warning, idx) => (
|
||||||
|
<div key={idx} className="agent-import-result-warning">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{importResult.skills && (
|
{importResult.skills && (
|
||||||
<>
|
<>
|
||||||
<div className="agent-import-result-divider" />
|
<div className="agent-import-result-divider" />
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ vi.mock("@fusion/core", () => {
|
|||||||
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
|
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
|
||||||
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
||||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||||
|
isEphemeralAgent: (agent: { metadata?: Record<string, unknown> }) =>
|
||||||
|
agent?.metadata?.agentKind === "task-worker",
|
||||||
deterministicGuardLocks: new Map(),
|
deterministicGuardLocks: new Map(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -367,6 +369,36 @@ describe("POST /api/agents/import", () => {
|
|||||||
expect(mockCreateAgent).not.toHaveBeenCalled();
|
expect(mockCreateAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns when imported agents are all role custom and no executor exists (issue #1261)", async () => {
|
||||||
|
mockListAgents.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const response = await postImport(app, {
|
||||||
|
manifest: "---\nname: YAML Agent\n---\nInstructions",
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = response.body as any;
|
||||||
|
expect(Array.isArray(body.warnings)).toBe(true);
|
||||||
|
expect(body.warnings[0]).toContain("custom");
|
||||||
|
expect(body.warnings[0]).toContain("executor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not warn when an eligible executor agent already exists", async () => {
|
||||||
|
mockListAgents.mockResolvedValue([
|
||||||
|
{ id: "exec-1", name: "Executor", role: "executor", state: "idle", metadata: {} },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await postImport(app, {
|
||||||
|
manifest: "---\nname: YAML Agent\n---\nInstructions",
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = response.body as any;
|
||||||
|
expect(body.warnings).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("includes manifest memory in dry-run preview", async () => {
|
it("includes manifest memory in dry-run preview", async () => {
|
||||||
const memory = "Capture operational constraints and open risks before each handoff.";
|
const memory = "Capture operational constraints and open risks before each handoff.";
|
||||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
|
|
||||||
import { Router, type Request, type Response, type NextFunction } from "express";
|
import { Router, type Request, type Response, type NextFunction } from "express";
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { TaskStore, resolvePlanningSettingsModel } from "@fusion/core";
|
import { TaskStore, resolvePlanningSettingsModel, AgentStore } from "@fusion/core";
|
||||||
import type { Goal } from "@fusion/core";
|
import type { Goal } from "@fusion/core";
|
||||||
|
import { listEligibleExecutorAgents } from "@fusion/engine";
|
||||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||||
import type {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
@@ -2998,6 +2999,29 @@ export function createMissionRouter(
|
|||||||
throw badRequest("No pending slices found");
|
throw badRequest("No pending slices found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preflight: when ephemeral agents are disabled, mission tasks can only be
|
||||||
|
// run by a permanent executor agent. Catalog-imported "company" agents land
|
||||||
|
// with role "custom" and are never auto-assigned, so without an executor the
|
||||||
|
// mission's tasks silently queue forever with no error surfaced (issue #1261).
|
||||||
|
// Block the start with an actionable message instead of stalling invisibly.
|
||||||
|
// Mirrors the scheduler's dispatch gate (ephemeralAgentsEnabled===false +
|
||||||
|
// selectPermanentAgentForTask returns null → task queued); both go through
|
||||||
|
// listEligibleExecutorAgents so the preflight can't drift from dispatch.
|
||||||
|
const scopedStore = getScopedStore();
|
||||||
|
const startSettings = await scopedStore.getSettings();
|
||||||
|
if (startSettings.ephemeralAgentsEnabled === false) {
|
||||||
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
|
await agentStore.init();
|
||||||
|
const executors = await listEligibleExecutorAgents(agentStore);
|
||||||
|
if (executors.length === 0) {
|
||||||
|
throw badRequest(
|
||||||
|
"Cannot start mission: ephemeral agents are disabled and no executor agent is available to run its tasks. "
|
||||||
|
+ "Imported catalog (\"company\") agents have role \"custom\" and are not auto-assigned mission work. "
|
||||||
|
+ "Assign at least one agent the \"executor\" role, or re-enable ephemeral agents in settings.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enable autopilot (and autoAdvance for backward compat) so the mission
|
// Enable autopilot (and autoAdvance for backward compat) so the mission
|
||||||
// will auto-advance slices when autopilot is watching
|
// will auto-advance slices when autopilot is watching
|
||||||
missionStore.updateMission(missionId, {
|
missionStore.updateMission(missionId, {
|
||||||
|
|||||||
@@ -732,6 +732,25 @@ async function persistImportedSkills(
|
|||||||
throw badRequest("No agents or skills found in manifest");
|
throw badRequest("No agents or skills found in manifest");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn when the imported agents can't actually be assigned mission/queue
|
||||||
|
// work: catalog ("company") agents land with role "custom", which is never
|
||||||
|
// auto-assigned. If none of the imported agents are executors and no
|
||||||
|
// executor already exists, missions run by these agents would stall or
|
||||||
|
// fail invisibly (issue #1261). Surface this up front, not after the fact.
|
||||||
|
const importWarnings: string[] = [];
|
||||||
|
const customRoleCount = importItems.filter((item) => item.input.role === "custom").length;
|
||||||
|
const importsAnExecutor = importItems.some((item) => item.input.role === "executor");
|
||||||
|
if (customRoleCount > 0 && !importsAnExecutor) {
|
||||||
|
const { listEligibleExecutorAgents } = await import("@fusion/engine");
|
||||||
|
const existingExecutors = await listEligibleExecutorAgents(agentStore).catch(() => []);
|
||||||
|
if (existingExecutors.length === 0) {
|
||||||
|
importWarnings.push(
|
||||||
|
`${customRoleCount} imported agent(s) have role "custom" and won't be auto-assigned mission or queue work. `
|
||||||
|
+ `Assign at least one agent the "executor" role, or keep ephemeral agents enabled, before starting a mission.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
const agentPreview = importItems.map((item) => ({
|
const agentPreview = importItems.map((item) => ({
|
||||||
name: item.input.name,
|
name: item.input.name,
|
||||||
@@ -766,6 +785,7 @@ async function persistImportedSkills(
|
|||||||
created: result.created,
|
created: result.created,
|
||||||
skipped: result.skipped,
|
skipped: result.skipped,
|
||||||
errors: result.errors,
|
errors: result.errors,
|
||||||
|
...(importWarnings.length > 0 ? { warnings: importWarnings } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -834,6 +854,7 @@ async function persistImportedSkills(
|
|||||||
errors,
|
errors,
|
||||||
skillsCount: (pkg.skills ?? []).length,
|
skillsCount: (pkg.skills ?? []).length,
|
||||||
skills: skillImportResult,
|
skills: skillImportResult,
|
||||||
|
...(importWarnings.length > 0 ? { warnings: importWarnings } : {}),
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Agent, Task } from "@fusion/core";
|
import type { Agent, Task } from "@fusion/core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { selectPermanentAgentForTask } from "../agent-assignment.js";
|
import { listEligibleExecutorAgents, selectPermanentAgentForTask } from "../agent-assignment.js";
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id">): Agent {
|
function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id">): Agent {
|
||||||
return {
|
return {
|
||||||
@@ -136,3 +136,30 @@ describe("selectPermanentAgentForTask", () => {
|
|||||||
expect(selected?.id).toBe("agent-b");
|
expect(selected?.id).toBe("agent-b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("listEligibleExecutorAgents", () => {
|
||||||
|
it("returns empty when only custom-role (catalog-imported) agents exist", async () => {
|
||||||
|
const eligible = await listEligibleExecutorAgents({
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "gstack-1", role: "custom" }),
|
||||||
|
makeAgent({ id: "gstack-2", role: "custom" }),
|
||||||
|
],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(eligible).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes ephemeral, disabled, and errored executors but keeps healthy ones", async () => {
|
||||||
|
const eligible = await listEligibleExecutorAgents({
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "ephemeral", metadata: { agentKind: "task-worker" } }),
|
||||||
|
makeAgent({ id: "disabled", runtimeConfig: { enabled: false } }),
|
||||||
|
makeAgent({ id: "errored", state: "error" }),
|
||||||
|
makeAgent({ id: "reviewer", role: "reviewer" }),
|
||||||
|
makeAgent({ id: "ok" }),
|
||||||
|
],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(eligible.map((agent) => agent.id)).toEqual(["ok"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -399,6 +399,27 @@ describe("MissionAutopilot", () => {
|
|||||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks immediately without retrying on an operator-actionable error", async () => {
|
||||||
|
const { feature } = wireMissionTask();
|
||||||
|
autopilot.watchMission("M-TEST1");
|
||||||
|
taskStore.getTask.mockResolvedValue({
|
||||||
|
id: "FN-001",
|
||||||
|
column: "in-review",
|
||||||
|
error:
|
||||||
|
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'",
|
||||||
|
});
|
||||||
|
|
||||||
|
await autopilot.handleTaskFailure("FN-001");
|
||||||
|
|
||||||
|
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith(feature.id, "blocked");
|
||||||
|
expect(taskStore.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.objectContaining({ status: "failed", paused: true }),
|
||||||
|
);
|
||||||
|
// No retry: the task must not be requeued to todo.
|
||||||
|
expect(taskStore.moveTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("marks feature blocked after max retries and does not retry again", async () => {
|
it("marks feature blocked after max retries and does not retry again", async () => {
|
||||||
const { feature } = wireMissionTask();
|
const { feature } = wireMissionTask();
|
||||||
autopilot.watchMission("M-TEST1");
|
autopilot.watchMission("M-TEST1");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, getProjectRootFromWorktree, promptWithFallback, type AgentOptions } from "../pi.js";
|
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, getProjectRootFromWorktree, isRetryableModelSelectionError, promptWithFallback, type AgentOptions } from "../pi.js";
|
||||||
import { createAgentSession, type AgentSession } from "@earendil-works/pi-coding-agent";
|
import { createAgentSession, type AgentSession } from "@earendil-works/pi-coding-agent";
|
||||||
import { piLog } from "../logger.js";
|
import { piLog } from "../logger.js";
|
||||||
|
|
||||||
@@ -910,3 +910,24 @@ describe("piLog structured diagnostics", () => {
|
|||||||
expect(errorSpy).not.toHaveBeenCalled();
|
expect(errorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isRetryableModelSelectionError", () => {
|
||||||
|
it("treats an unsupported message-role rejection as model-selection retryable so the fallback model is tried (issue #1261)", () => {
|
||||||
|
expect(
|
||||||
|
isRetryableModelSelectionError(
|
||||||
|
"developer is not one of ['system', 'assistant', 'user', 'tool', 'function'] - 'messages.[0].role'",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still matches the existing auth/rate-limit/capacity signals", () => {
|
||||||
|
expect(isRetryableModelSelectionError("invalid api key")).toBe(true);
|
||||||
|
expect(isRetryableModelSelectionError("HTTP 429 too many requests")).toBe(true);
|
||||||
|
expect(isRetryableModelSelectionError("model is overloaded")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match unrelated errors", () => {
|
||||||
|
expect(isRetryableModelSelectionError("ENOENT: no such file or directory")).toBe(false);
|
||||||
|
expect(isRetryableModelSelectionError("syntax error near unexpected token")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ function isAgentEnabled(agent: Agent): boolean {
|
|||||||
return (agent.runtimeConfig?.enabled as boolean | undefined) !== false;
|
return (agent.runtimeConfig?.enabled as boolean | undefined) !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent, enabled, non-errored executor agents — the pool the scheduler can
|
||||||
|
* auto-assign mission/queue tasks to when ephemeral agents are disabled.
|
||||||
|
*
|
||||||
|
* Catalog-imported "company" agents land with role "custom" (see
|
||||||
|
* mapRoleToCapability) and are therefore NOT in this pool, which is why a
|
||||||
|
* mission can silently stall when ephemeral agents are off and the only agents
|
||||||
|
* present came from an import. Callers use this to preflight that situation.
|
||||||
|
*/
|
||||||
|
export async function listEligibleExecutorAgents(
|
||||||
|
agentStore: Pick<AgentStore, "listAgents">,
|
||||||
|
): Promise<Agent[]> {
|
||||||
|
const agents = await agentStore.listAgents({ role: "executor", includeEphemeral: true });
|
||||||
|
return agents.filter(
|
||||||
|
(agent) => agent.role === "executor"
|
||||||
|
&& !isEphemeralAgent(agent)
|
||||||
|
&& agent.state !== "error"
|
||||||
|
&& isAgentEnabled(agent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function taskLinksToScope(task: Pick<Task, "id" | "missionId" | "sliceId">, scopeTask: Pick<Task, "id" | "missionId" | "sliceId">): boolean {
|
function taskLinksToScope(task: Pick<Task, "id" | "missionId" | "sliceId">, scopeTask: Pick<Task, "id" | "missionId" | "sliceId">): boolean {
|
||||||
if (task.id === scopeTask.id) return false;
|
if (task.id === scopeTask.id) return false;
|
||||||
if (scopeTask.sliceId && task.sliceId === scopeTask.sliceId) return true;
|
if (scopeTask.sliceId && task.sliceId === scopeTask.sliceId) return true;
|
||||||
@@ -21,13 +42,7 @@ function taskLinksToScope(task: Pick<Task, "id" | "missionId" | "sliceId">, scop
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function selectPermanentAgentForTask({ task, agentStore, taskStore }: SelectPermanentAgentForTaskOptions): Promise<Agent | null> {
|
export async function selectPermanentAgentForTask({ task, agentStore, taskStore }: SelectPermanentAgentForTaskOptions): Promise<Agent | null> {
|
||||||
const allAgents = await agentStore.listAgents({ role: "executor", includeEphemeral: true });
|
const eligibleAgents = await listEligibleExecutorAgents(agentStore);
|
||||||
const eligibleAgents = allAgents.filter(
|
|
||||||
(agent) => agent.role === "executor"
|
|
||||||
&& !isEphemeralAgent(agent)
|
|
||||||
&& agent.state !== "error"
|
|
||||||
&& isAgentEnabled(agent),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (eligibleAgents.length === 0) {
|
if (eligibleAgents.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export {
|
|||||||
type InteractiveAgentResult,
|
type InteractiveAgentResult,
|
||||||
type InteractiveAgentFactory,
|
type InteractiveAgentFactory,
|
||||||
} from "./interactive-ai-session.js";
|
} from "./interactive-ai-session.js";
|
||||||
|
export { selectPermanentAgentForTask, listEligibleExecutorAgents } from "./agent-assignment.js";
|
||||||
|
|
||||||
// Register createFnAgent into core's loader so consumers in @fusion/core
|
// Register createFnAgent into core's loader so consumers in @fusion/core
|
||||||
// (e.g. ai-summarize, memory-compaction) can resolve it without a circular
|
// (e.g. ai-summarize, memory-compaction) can resolve it without a circular
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { autopilotLog } from "./logger.js";
|
import { autopilotLog } from "./logger.js";
|
||||||
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
|
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
|
||||||
|
import { isOperatorActionableAgentError } from "./transient-error-detector.js";
|
||||||
|
|
||||||
/** Maximum retry attempts for slice activation failures. */
|
/** Maximum retry attempts for slice activation failures. */
|
||||||
const MAX_RETRY_ATTEMPTS = 3;
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
@@ -312,6 +313,25 @@ export class MissionAutopilot {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Operator-actionable failures (e.g. a model/provider that rejects the
|
||||||
|
// "developer" system role, or auth/quota errors) will fail identically on
|
||||||
|
// every retry. Retrying them just re-runs the same cryptic error N times —
|
||||||
|
// the "stuck in a loop" symptom from issue #1261. Stop immediately: block
|
||||||
|
// the feature and surface a clear operator-action event instead of burning
|
||||||
|
// the retry budget.
|
||||||
|
const failedTask = await this.taskStore.getTask(taskId).catch(() => null);
|
||||||
|
if (failedTask?.error && isOperatorActionableAgentError(failedTask.error)) {
|
||||||
|
this.missionStore.updateFeatureStatus(feature.id, "blocked");
|
||||||
|
await this.taskStore.updateTask(taskId, { status: "failed", paused: true });
|
||||||
|
this.logMissionEventSafe(
|
||||||
|
missionId,
|
||||||
|
"error",
|
||||||
|
`Feature ${feature.id} blocked: task ${taskId} hit an operator-actionable error that will not resolve on retry. ${failedTask.error}`,
|
||||||
|
{ taskId, featureId: feature.id, operatorActionable: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const settings = await this.taskStore.getSettings();
|
const settings = await this.taskStore.getSettings();
|
||||||
const maxRetries = settings.missionMaxTaskRetries ?? DEFAULT_MAX_TASK_RETRIES;
|
const maxRetries = settings.missionMaxTaskRetries ?? DEFAULT_MAX_TASK_RETRIES;
|
||||||
const missionRetries = this.perMissionTaskRetries.get(missionId) ?? new Map<string, number>();
|
const missionRetries = this.perMissionTaskRetries.get(missionId) ?? new Map<string, number>();
|
||||||
|
|||||||
@@ -1027,7 +1027,17 @@ function resolveConfiguredModel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRetryableModelSelectionError(message: string): boolean {
|
export function isRetryableModelSelectionError(message: string): boolean {
|
||||||
|
// An unsupported message-role rejection (e.g. a reasoning model sending the
|
||||||
|
// "developer" system role to a provider that only accepts
|
||||||
|
// system/user/assistant/tool) is fundamentally a model+provider
|
||||||
|
// compatibility problem. Treat it as a model-selection error so a configured
|
||||||
|
// fallback model is tried once before the task is marked failed. The
|
||||||
|
// `usingFallback` guard upstream keeps this to a single swap, so an
|
||||||
|
// incompatible fallback fails terminally rather than looping.
|
||||||
|
if (isUnsupportedMessageRoleError(message)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const normalized = message.toLowerCase();
|
const normalized = message.toLowerCase();
|
||||||
return normalized.includes("rate limit")
|
return normalized.includes("rate limit")
|
||||||
|| normalized.includes("too many requests")
|
|| normalized.includes("too many requests")
|
||||||
|
|||||||
Reference in New Issue
Block a user