diff --git a/.changeset/fn-5941-mission-incompatible-agent-guard.md b/.changeset/fn-5941-mission-incompatible-agent-guard.md
new file mode 100644
index 0000000000..95e9975dde
--- /dev/null
+++ b/.changeset/fn-5941-mission-incompatible-agent-guard.md
@@ -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.
diff --git a/packages/dashboard/app/components/AgentImportModal.css b/packages/dashboard/app/components/AgentImportModal.css
index 2a16e9f7bf..332182c05c 100644
--- a/packages/dashboard/app/components/AgentImportModal.css
+++ b/packages/dashboard/app/components/AgentImportModal.css
@@ -164,6 +164,32 @@
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 {
align-self: stretch;
height: 1px;
diff --git a/packages/dashboard/app/components/AgentImportModal.tsx b/packages/dashboard/app/components/AgentImportModal.tsx
index 7d750dda43..a4a12ae6a4 100644
--- a/packages/dashboard/app/components/AgentImportModal.tsx
+++ b/packages/dashboard/app/components/AgentImportModal.tsx
@@ -43,6 +43,7 @@ interface ImportResult {
skipped: string[];
errors: Array<{ name: string; error: string }>;
skills?: SkillImportResult;
+ warnings?: string[];
}
interface DirectoryAgentInput {
@@ -845,6 +846,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
)}
+ {importResult.warnings && importResult.warnings.length > 0 && (
+
+ {importResult.warnings.map((warning, idx) => (
+
+ ))}
+
+ )}
+
{importResult.skills && (
<>
diff --git a/packages/dashboard/src/__tests__/routes-agent-import.test.ts b/packages/dashboard/src/__tests__/routes-agent-import.test.ts
index 7cfa1a0706..1f12bf6382 100644
--- a/packages/dashboard/src/__tests__/routes-agent-import.test.ts
+++ b/packages/dashboard/src/__tests__/routes-agent-import.test.ts
@@ -72,6 +72,8 @@ vi.mock("@fusion/core", () => {
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
AgentCompaniesParseError: MockAgentCompaniesParseError,
+ isEphemeralAgent: (agent: { metadata?: Record }) =>
+ agent?.metadata?.agentKind === "task-worker",
deterministicGuardLocks: new Map(),
};
});
@@ -367,6 +369,36 @@ describe("POST /api/agents/import", () => {
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 () => {
const memory = "Capture operational constraints and open risks before each handoff.";
mockPrepareAgentCompaniesImport.mockReturnValue({
diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts
index 1f9ae493e6..fe8ebcd309 100644
--- a/packages/dashboard/src/mission-routes.ts
+++ b/packages/dashboard/src/mission-routes.ts
@@ -14,8 +14,9 @@
import { Router, type Request, type Response, type NextFunction } from "express";
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 { listEligibleExecutorAgents } from "@fusion/engine";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
import type {
Mission,
@@ -2998,6 +2999,29 @@ export function createMissionRouter(
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
// will auto-advance slices when autopilot is watching
missionStore.updateMission(missionId, {
diff --git a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts
index 4703158d9c..8d4317b762 100644
--- a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts
+++ b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts
@@ -732,6 +732,25 @@ async function persistImportedSkills(
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) {
const agentPreview = importItems.map((item) => ({
name: item.input.name,
@@ -766,6 +785,7 @@ async function persistImportedSkills(
created: result.created,
skipped: result.skipped,
errors: result.errors,
+ ...(importWarnings.length > 0 ? { warnings: importWarnings } : {}),
});
return;
}
@@ -834,6 +854,7 @@ async function persistImportedSkills(
errors,
skillsCount: (pkg.skills ?? []).length,
skills: skillImportResult,
+ ...(importWarnings.length > 0 ? { warnings: importWarnings } : {}),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
diff --git a/packages/engine/src/__tests__/agent-assignment.test.ts b/packages/engine/src/__tests__/agent-assignment.test.ts
index 1ab9d9182a..4561d77b5f 100644
--- a/packages/engine/src/__tests__/agent-assignment.test.ts
+++ b/packages/engine/src/__tests__/agent-assignment.test.ts
@@ -1,6 +1,6 @@
import type { Agent, Task } from "@fusion/core";
import { describe, expect, it } from "vitest";
-import { selectPermanentAgentForTask } from "../agent-assignment.js";
+import { listEligibleExecutorAgents, selectPermanentAgentForTask } from "../agent-assignment.js";
function makeAgent(overrides: Partial & Pick): Agent {
return {
@@ -136,3 +136,30 @@ describe("selectPermanentAgentForTask", () => {
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"]);
+ });
+});
diff --git a/packages/engine/src/__tests__/mission-autopilot.test.ts b/packages/engine/src/__tests__/mission-autopilot.test.ts
index bd2228ce32..99b805f0bd 100644
--- a/packages/engine/src/__tests__/mission-autopilot.test.ts
+++ b/packages/engine/src/__tests__/mission-autopilot.test.ts
@@ -399,6 +399,27 @@ describe("MissionAutopilot", () => {
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 () => {
const { feature } = wireMissionTask();
autopilot.watchMission("M-TEST1");
diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts
index fbf52e22ab..5daaefc147 100644
--- a/packages/engine/src/__tests__/pi.test.ts
+++ b/packages/engine/src/__tests__/pi.test.ts
@@ -1,5 +1,5 @@
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 { piLog } from "../logger.js";
@@ -910,3 +910,24 @@ describe("piLog structured diagnostics", () => {
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);
+ });
+});
diff --git a/packages/engine/src/agent-assignment.ts b/packages/engine/src/agent-assignment.ts
index 7da2a03b53..2696d027db 100644
--- a/packages/engine/src/agent-assignment.ts
+++ b/packages/engine/src/agent-assignment.ts
@@ -13,6 +13,27 @@ function isAgentEnabled(agent: Agent): boolean {
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,
+): Promise {
+ 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, scopeTask: Pick): boolean {
if (task.id === scopeTask.id) return false;
if (scopeTask.sliceId && task.sliceId === scopeTask.sliceId) return true;
@@ -21,13 +42,7 @@ function taskLinksToScope(task: Pick, scop
}
export async function selectPermanentAgentForTask({ task, agentStore, taskStore }: SelectPermanentAgentForTaskOptions): Promise {
- const allAgents = await agentStore.listAgents({ role: "executor", includeEphemeral: true });
- const eligibleAgents = allAgents.filter(
- (agent) => agent.role === "executor"
- && !isEphemeralAgent(agent)
- && agent.state !== "error"
- && isAgentEnabled(agent),
- );
+ const eligibleAgents = await listEligibleExecutorAgents(agentStore);
if (eligibleAgents.length === 0) {
return null;
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 863a212463..01c25051b8 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -119,6 +119,7 @@ export {
type InteractiveAgentResult,
type InteractiveAgentFactory,
} from "./interactive-ai-session.js";
+export { selectPermanentAgentForTask, listEligibleExecutorAgents } from "./agent-assignment.js";
// Register createFnAgent into core's loader so consumers in @fusion/core
// (e.g. ai-summarize, memory-compaction) can resolve it without a circular
diff --git a/packages/engine/src/mission-autopilot.ts b/packages/engine/src/mission-autopilot.ts
index e0a6dcfce5..b52aff576b 100644
--- a/packages/engine/src/mission-autopilot.ts
+++ b/packages/engine/src/mission-autopilot.ts
@@ -30,6 +30,7 @@ import type {
} from "@fusion/core";
import { autopilotLog } from "./logger.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
+import { isOperatorActionableAgentError } from "./transient-error-detector.js";
/** Maximum retry attempts for slice activation failures. */
const MAX_RETRY_ATTEMPTS = 3;
@@ -312,6 +313,25 @@ export class MissionAutopilot {
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 maxRetries = settings.missionMaxTaskRetries ?? DEFAULT_MAX_TASK_RETRIES;
const missionRetries = this.perMissionTaskRetries.get(missionId) ?? new Map();
diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts
index 39aaf0d8b2..227972480a 100644
--- a/packages/engine/src/pi.ts
+++ b/packages/engine/src/pi.ts
@@ -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();
return normalized.includes("rate limit")
|| normalized.includes("too many requests")