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..42cc1a0f32 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 { @@ -136,6 +137,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi const [companyName, setCompanyName] = useState("Unknown"); const [agents, setAgents] = useState([]); const [skills, setSkills] = useState([]); + const [previewWarnings, setPreviewWarnings] = useState([]); const [selectedAgentNames, setSelectedAgentNames] = useState([]); const [selectedSkillNames, setSelectedSkillNames] = useState([]); const [isParsing, setIsParsing] = useState(false); @@ -214,6 +216,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi setCompanyName("Unknown"); setAgents([]); setSkills([]); + setPreviewWarnings([]); setSelectedAgentNames([]); setSelectedSkillNames([]); setIsParsing(false); @@ -344,6 +347,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi created: string[]; skipped: string[]; errors: Array<{ name: string; error: string }>; + warnings?: string[]; }; const previewAgents = (data.agents && data.agents.length > 0) @@ -354,6 +358,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi setCompanyName(data.companyName ?? "Unknown"); setAgents(previewAgents); setSkills(previewSkills); + setPreviewWarnings(Array.isArray(data.warnings) ? data.warnings : []); setSelectedAgentNames(previewAgents.map((agent) => agent.name)); setSelectedSkillNames(previewSkills.map((skill) => skill.name)); setStep("preview"); @@ -677,6 +682,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi {companyName} + {previewWarnings.length > 0 && ( +
+ {previewWarnings.map((warning, idx) => ( +
+ + {warning} +
+ ))} +
+ )} +
{agents.length} agent{agents.length !== 1 ? "s" : ""} found @@ -845,6 +861,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
)} + {importResult.warnings && importResult.warnings.length > 0 && ( +
+ {importResult.warnings.map((warning, idx) => ( +
+ + {warning} +
+ ))} +
+ )} + {importResult.skills && ( <>
diff --git a/packages/dashboard/app/components/__tests__/AgentImportModal.test.tsx b/packages/dashboard/app/components/__tests__/AgentImportModal.test.tsx index 9922f5c06a..5958c61896 100644 --- a/packages/dashboard/app/components/__tests__/AgentImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentImportModal.test.tsx @@ -110,6 +110,36 @@ describe("AgentImportModal", () => { }); }); + it("surfaces dry-run warnings in the preview step, before import (issue #1261)", async () => { + vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({ + ok: true, + status: 200, + body: { + dryRun: true, + companyName: "Acme Co", + agents: [{ name: "CEO", role: "custom" }], + created: ["CEO"], + skipped: [], + errors: [], + warnings: [ + "1 imported agent(s) have role \"custom\" and won't be auto-assigned mission or queue work.", + ], + }, + })); + + render(); + + fireEvent.change(screen.getByLabelText("Manifest content"), { + target: { value: "---\nname: CEO\n---\nLead" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Preview" })); + + await waitFor(() => { + expect(screen.getByText(/won't be auto-assigned mission or queue work/)).toBeTruthy(); + }); + }); + it("imports agents from preview step and shows result summary", async () => { vi.mocked(globalThis.fetch) .mockImplementationOnce(() => mockFetchResponse({ diff --git a/packages/dashboard/src/__tests__/routes-agent-import.test.ts b/packages/dashboard/src/__tests__/routes-agent-import.test.ts index 7cfa1a0706..82f9997ff4 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,53 @@ 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("includes the custom-role warning on a live (non-dry-run) import too (issue #1261)", async () => { + mockListAgents.mockResolvedValue([]); + + const response = await postImport(app, { + manifest: "---\nname: YAML Agent\n---\nInstructions", + }); + + expect(response.status).toBe(200); + const body = response.body as any; + // Live import actually persists the agent... + expect(body.created).toHaveLength(1); + // ...and still surfaces the warning (the path the dry-run test can't cover). + 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..ccec071bf4 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 @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Readable } from "node:stream"; import { pipeline as streamPipeline } from "node:stream/promises"; +import { listEligibleExecutorAgents } from "@fusion/engine"; import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js"; import { createSessionDiagnostics } from "../ai-session-diagnostics.js"; import { writeSSEEvent } from "../sse-buffer.js"; @@ -732,6 +733,24 @@ 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 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")