Merge pull request #1362 from Runfusion/gsxdsm/mission-generator-does-not-respect-or-assign-to

FN-5941: stop missions stalling on incompatible/custom-role agents
This commit is contained in:
gsxdsm
2026-06-03 17:38:19 -07:00
committed by GitHub
14 changed files with 315 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);
}
.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;

View File

@@ -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<AgentPreview[]>([]);
const [skills, setSkills] = useState<SkillPreview[]>([]);
const [previewWarnings, setPreviewWarnings] = useState<string[]>([]);
const [selectedAgentNames, setSelectedAgentNames] = useState<string[]>([]);
const [selectedSkillNames, setSelectedSkillNames] = useState<string[]>([]);
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
<span className="agent-import-company-name">{companyName}</span>
</div>
{previewWarnings.length > 0 && (
<div className="agent-import-result-warnings">
{previewWarnings.map((warning, idx) => (
<div key={idx} className="agent-import-result-warning">
<AlertTriangle size={12} />
<span>{warning}</span>
</div>
))}
</div>
)}
<div className="agent-import-count">
<FileText size={14} />
<span>{agents.length} agent{agents.length !== 1 ? "s" : ""} found</span>
@@ -845,6 +861,17 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId, initi
</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 && (
<>
<div className="agent-import-result-divider" />

View File

@@ -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(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
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({

View File

@@ -72,6 +72,8 @@ vi.mock("@fusion/core", () => {
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
AgentCompaniesParseError: MockAgentCompaniesParseError,
isEphemeralAgent: (agent: { metadata?: Record<string, unknown> }) =>
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({

View File

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

View File

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

View File

@@ -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<Agent> & Pick<Agent, "id">): 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"]);
});
});

View File

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

View File

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

View File

@@ -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<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 {
if (task.id === scopeTask.id) return false;
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> {
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;

View File

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

View File

@@ -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<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();
return normalized.includes("rate limit")
|| normalized.includes("too many requests")