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:
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user