From 570b51d07bb0614838c991731caa954fc5c8dff7 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 00:39:31 -0500 Subject: [PATCH 1/4] feat(skill-mcp): add HTTP transport support for remote MCP servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for connecting to remote MCP servers via HTTP in addition to the existing stdio (local process) connections. This enables skills to use cloud-hosted MCP servers and aggregated MCP gateways. ## Changes - Extend SkillMcpManager to detect connection type from config: - Explicit `type: "http"` or `type: "sse"` → HTTP connection - Explicit `type: "stdio"` → stdio connection - Infer from `url` field → HTTP connection - Infer from `command` field → stdio connection - Add StreamableHTTPClientTransport from MCP SDK for HTTP connections - Supports custom headers for authentication (e.g., API keys) - Proper error handling with helpful hints - Maintain full backward compatibility with existing stdio configurations ## Usage ```yaml # HTTP connection (new) mcp: remote-server: url: https://mcp.example.com/mcp headers: Authorization: Bearer ${API_KEY} # stdio connection (existing, unchanged) mcp: local-server: command: npx args: [-y, @some/mcp-server] ``` ## Tests Added comprehensive tests for: - Connection type detection (explicit type vs inferred) - HTTP URL validation and error messages - Headers configuration - Backward compatibility with stdio configs --- .../skill-mcp-manager/manager.test.ts | 328 ++++++++++++++++-- src/features/skill-mcp-manager/manager.ts | 178 +++++++++- 2 files changed, 472 insertions(+), 34 deletions(-) diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index 2313e22e..4f43247a 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -15,34 +15,272 @@ describe("SkillMcpManager", () => { }) describe("getOrCreateClient", () => { - it("throws error when command is missing", async () => { - // #given - const info: SkillMcpClientInfo = { - serverName: "test-server", - skillName: "test-skill", - sessionID: "session-1", - } - const config: ClaudeCodeMcpServer = {} + describe("configuration validation", () => { + it("throws error when neither url nor command is provided", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} - // #when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /missing required 'command' field/ - ) + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /no valid connection configuration/ + ) + }) + + it("includes both HTTP and stdio examples in error message", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "my-mcp", + skillName: "data-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /HTTP[\s\S]*Stdio/ + ) + }) + + it("includes server and skill names in error message", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "custom-server", + skillName: "custom-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = {} + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /custom-server[\s\S]*custom-skill/ + ) + }) }) - it("includes helpful error message with example when command is missing", async () => { - // #given - const info: SkillMcpClientInfo = { - serverName: "my-mcp", - skillName: "data-skill", - sessionID: "session-1", - } - const config: ClaudeCodeMcpServer = {} + describe("connection type detection", () => { + it("detects HTTP connection from explicit type='http'", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "http-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "http", + url: "https://example.com/mcp", + } - // #when / #then - await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( - /my-mcp[\s\S]*data-skill[\s\S]*Example/ - ) + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + + it("detects HTTP connection from explicit type='sse'", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "sse-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "sse", + url: "https://example.com/mcp", + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + + it("detects HTTP connection from url field when type is not specified", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "inferred-http", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect[\s\S]*URL/ + ) + }) + + it("detects stdio connection from explicit type='stdio'", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "stdio-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "stdio", + command: "node", + args: ["-e", "process.exit(0)"], + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect[\s\S]*Command/ + ) + }) + + it("detects stdio connection from command field when type is not specified", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "inferred-stdio", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + command: "node", + args: ["-e", "process.exit(0)"], + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect[\s\S]*Command/ + ) + }) + + it("prefers explicit type over inferred type", async () => { + // #given - has both url and command, but type is explicitly stdio + const info: SkillMcpClientInfo = { + serverName: "mixed-config", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "stdio", + url: "https://example.com/mcp", // should be ignored + command: "node", + args: ["-e", "process.exit(0)"], + } + + // #when / #then - should use stdio (show Command in error, not URL) + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Command: node/ + ) + }) + }) + + describe("HTTP connection", () => { + it("throws error for invalid URL", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "bad-url-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "http", + url: "not-a-valid-url", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /invalid URL/ + ) + }) + + it("includes URL in HTTP connection error", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "http-error-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://nonexistent.example.com/mcp", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /https:\/\/nonexistent\.example\.com\/mcp/ + ) + }) + + it("includes helpful hints for HTTP connection failures", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "hint-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://nonexistent.example.com/mcp", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/ + ) + }) + }) + + describe("stdio connection (backward compatibility)", () => { + it("throws error when command is missing for stdio type", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "missing-command", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + type: "stdio", + // command is missing + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /missing 'command' field/ + ) + }) + + it("includes command in stdio connection error", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + command: "nonexistent-command-xyz", + args: ["--foo"], + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /nonexistent-command-xyz --foo/ + ) + }) + + it("includes helpful hints for stdio connection failures", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + command: "nonexistent-command", + } + + // #when / #then + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Hints[\s\S]*PATH[\s\S]*package exists/ + ) + }) }) }) @@ -156,4 +394,46 @@ describe("SkillMcpManager", () => { } }) }) + + describe("HTTP headers handling", () => { + it("accepts configuration with headers", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "auth-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }, + } + + // #when / #then - should fail at connection, not config validation + // Headers are passed through to the transport + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + + it("works without headers (optional)", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "no-auth-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + // no headers + } + + // #when / #then - should fail at connection, not config validation + await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( + /Failed to connect/ + ) + }) + }) }) diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts index 089e186e..7741b8ee 100644 --- a/src/features/skill-mcp-manager/manager.ts +++ b/src/features/skill-mcp-manager/manager.ts @@ -1,16 +1,60 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander" import { createCleanMcpEnvironment } from "./env-cleaner" import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" -interface ManagedClient { +/** + * Connection type for a managed MCP client. + * - "stdio": Local process via stdin/stdout + * - "http": Remote server via HTTP (Streamable HTTP transport) + */ +type ConnectionType = "stdio" | "http" + +interface ManagedClientBase { client: Client - transport: StdioClientTransport skillName: string lastUsedAt: number + connectionType: ConnectionType +} + +interface ManagedStdioClient extends ManagedClientBase { + connectionType: "stdio" + transport: StdioClientTransport +} + +interface ManagedHttpClient extends ManagedClientBase { + connectionType: "http" + transport: StreamableHTTPClientTransport +} + +type ManagedClient = ManagedStdioClient | ManagedHttpClient + +/** + * Determines connection type from MCP server configuration. + * Priority: explicit type field > url presence > command presence + */ +function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null { + // Explicit type takes priority + if (config.type === "http" || config.type === "sse") { + return "http" + } + if (config.type === "stdio") { + return "stdio" + } + + // Infer from available fields + if (config.url) { + return "http" + } + if (config.command) { + return "stdio" + } + + return null } export class SkillMcpManager { @@ -98,18 +142,125 @@ export class SkillMcpManager { private async createClient( info: SkillMcpClientInfo, config: ClaudeCodeMcpServer + ): Promise { + const connectionType = getConnectionType(config) + + if (!connectionType) { + throw new Error( + `MCP server "${info.serverName}" has no valid connection configuration.\n\n` + + `The MCP configuration in skill "${info.skillName}" must specify either:\n` + + ` - A URL for HTTP connection (remote MCP server)\n` + + ` - A command for stdio connection (local MCP process)\n\n` + + `Examples:\n` + + ` HTTP:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` url: https://mcp.example.com/mcp\n` + + ` headers:\n` + + ` Authorization: Bearer \${API_KEY}\n\n` + + ` Stdio:\n` + + ` mcp:\n` + + ` ${info.serverName}:\n` + + ` command: npx\n` + + ` args: [-y, @some/mcp-server]` + ) + } + + if (connectionType === "http") { + return this.createHttpClient(info, config) + } else { + return this.createStdioClient(info, config) + } + } + + /** + * Create an HTTP-based MCP client using StreamableHTTPClientTransport. + * Supports remote MCP servers with optional authentication headers. + */ + private async createHttpClient( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer + ): Promise { + const key = this.getClientKey(info) + + if (!config.url) { + throw new Error( + `MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.` + ) + } + + let url: URL + try { + url = new URL(config.url) + } catch { + throw new Error( + `MCP server "${info.serverName}" has invalid URL: ${config.url}\n\n` + + `Expected a valid URL like: https://mcp.example.com/mcp` + ) + } + + this.registerProcessCleanup() + + // Build request init with headers if provided + const requestInit: RequestInit = {} + if (config.headers && Object.keys(config.headers).length > 0) { + requestInit.headers = config.headers + } + + const transport = new StreamableHTTPClientTransport(url, { + requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined, + }) + + const client = new Client( + { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" }, + { capabilities: {} } + ) + + try { + await client.connect(transport) + } catch (error) { + try { + await transport.close() + } catch { + // Transport may already be closed + } + const errorMessage = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to connect to MCP server "${info.serverName}".\n\n` + + `URL: ${config.url}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Verify the URL is correct and the server is running\n` + + ` - Check if authentication headers are required\n` + + ` - Ensure the server supports MCP over HTTP` + ) + } + + const managedClient: ManagedHttpClient = { + client, + transport, + skillName: info.skillName, + lastUsedAt: Date.now(), + connectionType: "http", + } + this.clients.set(key, managedClient) + this.startCleanupTimer() + return client + } + + /** + * Create a stdio-based MCP client using StdioClientTransport. + * Spawns a local process and communicates via stdin/stdout. + */ + private async createStdioClient( + info: SkillMcpClientInfo, + config: ClaudeCodeMcpServer ): Promise { const key = this.getClientKey(info) if (!config.command) { throw new Error( - `MCP server "${info.serverName}" is missing required 'command' field.\n\n` + - `The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` + - `Example:\n` + - ` mcp:\n` + - ` ${info.serverName}:\n` + - ` command: npx\n` + - ` args: [-y, @some/mcp-server]` + `MCP server "${info.serverName}" is configured for stdio but missing 'command' field.` ) } @@ -153,7 +304,14 @@ export class SkillMcpManager { ) } - this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() }) + const managedClient: ManagedStdioClient = { + client, + transport, + skillName: info.skillName, + lastUsedAt: Date.now(), + connectionType: "stdio", + } + this.clients.set(key, managedClient) this.startCleanupTimer() return client } From c9ef648c6088946e1fdfd01a0df317d8115c1f3f Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 00:57:38 -0500 Subject: [PATCH 2/4] test: mock StreamableHTTPClientTransport for faster, deterministic tests Add mocks for HTTP transport to avoid real network calls during tests. This addresses reviewer feedback about test reliability: - Tests are now faster (no network latency) - Tests are deterministic across environments - Test intent is clearer (unit testing error handling logic) The mock throws immediately with a controlled error message, allowing tests to validate error handling without network dependencies. Co-Authored-By: Claude Opus 4.5 --- clean_pr_body.txt | 44 ++++++++++++ .../skill-mcp-manager/manager.test.ts | 68 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 clean_pr_body.txt diff --git a/clean_pr_body.txt b/clean_pr_body.txt new file mode 100644 index 00000000..61fffdb6 --- /dev/null +++ b/clean_pr_body.txt @@ -0,0 +1,44 @@ +## Summary + +Adds an automatic model fallback system that gracefully handles API failures by retrying with configured fallback models. When primary models fail due to transient errors (rate limits, 5xx, network issues), the system automatically retries with fallback models. Background tasks also properly support model fallback chains for reliability. + +## Features + +Automatic Fallback on Transient Errors: +- Rate limits and HTTP 429 errors +- Service unavailable (HTTP 503, 502) +- Capacity issues (overloaded, capacity, unavailable) +- Network errors (timeout, ECONNREFUSED, ENOTFOUND) + +Non-model errors like authentication failures or invalid input are NOT retried. + +Background Task Support: +- Background tasks properly use modelChain for fallback +- Ensures reliability for async operations + +## Implementation Details + +Core module src/features/model-fallback/ provides utilities for: +- parseModelString: Parse provider/model format +- buildModelChain: Build ordered list of models +- isModelError: Detect retryable errors +- withModelFallback: Generic retry wrapper with validation +- formatRetryErrors: Format error list for display + +Integration with sisyphus-task includes updated resolveCategoryConfig, inline retry logic, and special handling for configuration errors. + +## Fixes from Review + +✅ maxAttempts validated with Math.max(1, ...) to prevent silent failures +✅ Error matching scoped to agent.name errors only +✅ Background LaunchInput supports modelChain +✅ BackgroundTask stores modelChain field +✅ Background manager passes modelChain to prompt execution + +## Testing + +✅ 36/36 model-fallback tests passing +✅ Build successful +✅ TypeScript types valid + +This is a clean PR without auto-generated XML descriptions, fixing the authorship issue from PR #785. diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index 4f43247a..ff9816c5 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -3,11 +3,47 @@ import { SkillMcpManager } from "./manager" import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" + + +// Mock the MCP SDK transports to avoid network calls +const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure"))) +const mockHttpClose = mock(() => Promise.resolve()) +let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {} + +mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: class MockStreamableHTTPClientTransport { + constructor(public url: URL, public options?: { requestInit?: RequestInit }) { + lastTransportInstance = { url, options } + } + async start() { + await mockHttpConnect() + } + async close() { + await mockHttpClose() + } + }, +})) + + + + + + + + + + + + + + describe("SkillMcpManager", () => { let manager: SkillMcpManager beforeEach(() => { manager = new SkillMcpManager() + mockHttpConnect.mockClear() + mockHttpClose.mockClear() }) afterEach(async () => { @@ -226,6 +262,30 @@ describe("SkillMcpManager", () => { /Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/ ) }) + + it("calls mocked transport connect for HTTP connections", async () => { + // #given + const info: SkillMcpClientInfo = { + serverName: "mock-test-server", + skillName: "test-skill", + sessionID: "session-1", + } + const config: ClaudeCodeMcpServer = { + url: "https://example.com/mcp", + } + + // #when + try { + await manager.getOrCreateClient(info, config) + } catch { + // Expected to fail + } + + // #then - verify mock was called (transport was instantiated) + // The connection attempt happens through the Client.connect() which + // internally calls transport.start() + expect(mockHttpConnect).toHaveBeenCalled() + }) }) describe("stdio connection (backward compatibility)", () => { @@ -415,6 +475,14 @@ describe("SkillMcpManager", () => { // Headers are passed through to the transport await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( /Failed to connect/ + + // Verify headers were forwarded to transport + expect(lastTransportInstance.options?.requestInit?.headers).toEqual({ + Authorization: "Bearer test-token", + "X-Custom-Header": "custom-value", + }) + + ) }) From 951df07c0fb4662e96cb92e1ecbe3e8486058e06 Mon Sep 17 00:00:00 2001 From: stranger2904 Date: Wed, 14 Jan 2026 15:10:45 -0500 Subject: [PATCH 3/4] fix: correct test syntax for headers verification Fix syntax error where expect().rejects.toThrow() was not properly closed before the headers assertion. --- src/features/skill-mcp-manager/manager.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index ff9816c5..b77e74ef 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -475,15 +475,13 @@ describe("SkillMcpManager", () => { // Headers are passed through to the transport await expect(manager.getOrCreateClient(info, config)).rejects.toThrow( /Failed to connect/ + ) // Verify headers were forwarded to transport expect(lastTransportInstance.options?.requestInit?.headers).toEqual({ Authorization: "Bearer test-token", "X-Custom-Header": "custom-value", }) - - - ) }) it("works without headers (optional)", async () => { From 2c4730f0945b8832bf6eb945cdc00929c4dc5967 Mon Sep 17 00:00:00 2001 From: Kenny Date: Wed, 14 Jan 2026 15:27:48 -0500 Subject: [PATCH 4/4] Delete clean_pr_body.txt --- clean_pr_body.txt | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 clean_pr_body.txt diff --git a/clean_pr_body.txt b/clean_pr_body.txt deleted file mode 100644 index 61fffdb6..00000000 --- a/clean_pr_body.txt +++ /dev/null @@ -1,44 +0,0 @@ -## Summary - -Adds an automatic model fallback system that gracefully handles API failures by retrying with configured fallback models. When primary models fail due to transient errors (rate limits, 5xx, network issues), the system automatically retries with fallback models. Background tasks also properly support model fallback chains for reliability. - -## Features - -Automatic Fallback on Transient Errors: -- Rate limits and HTTP 429 errors -- Service unavailable (HTTP 503, 502) -- Capacity issues (overloaded, capacity, unavailable) -- Network errors (timeout, ECONNREFUSED, ENOTFOUND) - -Non-model errors like authentication failures or invalid input are NOT retried. - -Background Task Support: -- Background tasks properly use modelChain for fallback -- Ensures reliability for async operations - -## Implementation Details - -Core module src/features/model-fallback/ provides utilities for: -- parseModelString: Parse provider/model format -- buildModelChain: Build ordered list of models -- isModelError: Detect retryable errors -- withModelFallback: Generic retry wrapper with validation -- formatRetryErrors: Format error list for display - -Integration with sisyphus-task includes updated resolveCategoryConfig, inline retry logic, and special handling for configuration errors. - -## Fixes from Review - -✅ maxAttempts validated with Math.max(1, ...) to prevent silent failures -✅ Error matching scoped to agent.name errors only -✅ Background LaunchInput supports modelChain -✅ BackgroundTask stores modelChain field -✅ Background manager passes modelChain to prompt execution - -## Testing - -✅ 36/36 model-fallback tests passing -✅ Build successful -✅ TypeScript types valid - -This is a clean PR without auto-generated XML descriptions, fixing the authorship issue from PR #785.