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:
gsxdsm
2026-06-03 13:44:02 -07:00
parent e01848c293
commit 6a00dd2090
13 changed files with 253 additions and 11 deletions

View 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.

View File

@@ -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;

View File

@@ -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" />

View File

@@ -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({

View File

@@ -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, {

View File

@@ -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) {

View File

@@ -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"]);
});
});

View File

@@ -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");

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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

View File

@@ -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>();

View File

@@ -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")