From df967fe63c8b4fb6834ad66ae062bec9964890c6 Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Fri, 20 Mar 2026 12:07:00 -0400 Subject: [PATCH 1/2] fix(providers): honour request_timeout for CLI providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-cli, codex-cli, and gemini-cli ignored the request_timeout config field entirely — the factory created them without any timeout and the subprocesses ran indefinitely. This is particularly problematic for agentic tasks (e.g. cron jobs) where a runaway Claude Code session could block the agent loop forever. Each CLI provider gains a timeout field and a WithTimeout constructor. When request_timeout > 0 in the model config, Chat() wraps the incoming context with context.WithTimeout before passing it to exec.CommandContext, so the subprocess is killed and an error is returned if the deadline is exceeded. A value of 0 (the default) leaves behaviour unchanged — no timeout is applied. Co-Authored-By: Claude Sonnet 4.6 --- pkg/providers/claude_cli_provider.go | 17 +++++++++++++++++ pkg/providers/codex_cli_provider.go | 17 +++++++++++++++++ pkg/providers/factory_provider.go | 9 +++++++++ pkg/providers/gemini_cli_provider.go | 17 +++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index f514ba7f0..f63b6f00d 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -7,12 +7,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. type ClaudeCliProvider struct { command string workspace string + timeout time.Duration } // NewClaudeCliProvider creates a new Claude CLI provider. @@ -23,10 +25,25 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { } } +// NewClaudeCliProviderWithTimeout creates a new Claude CLI provider with a request timeout. +func NewClaudeCliProviderWithTimeout(workspace string, timeout time.Duration) *ClaudeCliProvider { + return &ClaudeCliProvider{ + command: "claude", + workspace: workspace, + timeout: timeout, + } +} + // Chat implements LLMProvider.Chat by executing the claude CLI. func (p *ClaudeCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildStdinPrompt(messages, tools) args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 13f53ad9e..5cfca37f8 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -8,12 +8,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. type CodexCliProvider struct { command string workspace string + timeout time.Duration } // NewCodexCliProvider creates a new Codex CLI provider. @@ -24,6 +26,15 @@ func NewCodexCliProvider(workspace string) *CodexCliProvider { } } +// NewCodexCliProviderWithTimeout creates a new Codex CLI provider with a request timeout. +func NewCodexCliProviderWithTimeout(workspace string, timeout time.Duration) *CodexCliProvider { + return &CodexCliProvider{ + command: "codex", + workspace: workspace, + timeout: timeout, + } +} + // Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. func (p *CodexCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, @@ -32,6 +43,12 @@ func (p *CodexCliProvider) Chat( return nil, fmt.Errorf("codex command not configured") } + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildPrompt(messages, tools) args := []string{ diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a460dec99..034f8b9c2 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -180,6 +180,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewClaudeCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewClaudeCliProvider(workspace), modelID, nil case "codex-cli", "codexcli": @@ -187,6 +190,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewCodexCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewCodexCliProvider(workspace), modelID, nil case "gemini-cli", "geminicli": @@ -194,6 +200,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if workspace == "" { workspace = "." } + if cfg.RequestTimeout > 0 { + return NewGeminiCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil + } return NewGeminiCliProvider(workspace), modelID, nil case "github-copilot", "copilot": diff --git a/pkg/providers/gemini_cli_provider.go b/pkg/providers/gemini_cli_provider.go index 0042535f0..2c644f378 100644 --- a/pkg/providers/gemini_cli_provider.go +++ b/pkg/providers/gemini_cli_provider.go @@ -7,12 +7,14 @@ import ( "fmt" "os/exec" "strings" + "time" ) // GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess. type GeminiCliProvider struct { command string workspace string + timeout time.Duration } // NewGeminiCliProvider creates a new Gemini CLI provider. @@ -23,10 +25,25 @@ func NewGeminiCliProvider(workspace string) *GeminiCliProvider { } } +// NewGeminiCliProviderWithTimeout creates a new Gemini CLI provider with a request timeout. +func NewGeminiCliProviderWithTimeout(workspace string, timeout time.Duration) *GeminiCliProvider { + return &GeminiCliProvider{ + command: "gemini", + workspace: workspace, + timeout: timeout, + } +} + // Chat implements LLMProvider.Chat by executing the gemini CLI. func (p *GeminiCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { + if p.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } + prompt := p.buildPrompt(messages, tools) // --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input. From 56ac14c0e548d49402590ea19373890218e144f2 Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Fri, 20 Mar 2026 12:23:56 -0400 Subject: [PATCH 2/2] fix(providers): clear timeout error message and fallback on CLI provider timeout When a CLI provider subprocess was killed due to request_timeout, the error reported was the raw subprocess signal ("signal: killed"), giving the user no indication that a timeout was the cause. Additionally, the fallback chain would not trigger because ClassifyError used == to match context.DeadlineExceeded, which does not match wrapped errors. - Each CLI provider now checks ctx.Err() == context.DeadlineExceeded after cmd.Run() fails and returns a descriptive error (e.g. "claude cli timed out after 30s") that wraps context.DeadlineExceeded - ClassifyError updated to use errors.Is instead of == when checking for context.DeadlineExceeded, so wrapped timeout errors correctly classify as FailoverTimeout and trigger fallback to the next candidate in the chain Co-Authored-By: Claude Sonnet 4.6 --- pkg/providers/claude_cli_provider.go | 3 +++ pkg/providers/codex_cli_provider.go | 3 +++ pkg/providers/error_classifier.go | 3 ++- pkg/providers/gemini_cli_provider.go | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index f63b6f00d..1183f5618 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -63,6 +63,9 @@ func (p *ClaudeCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("claude cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch { diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 5cfca37f8..7d347aa54 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -86,6 +86,9 @@ func (p *CodexCliProvider) Chat( } if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("codex cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } if ctx.Err() == context.Canceled { return nil, ctx.Err() } diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..46d3b4c82 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -2,6 +2,7 @@ package providers import ( "context" + "errors" "regexp" "strings" ) @@ -114,7 +115,7 @@ func ClassifyError(err error, provider, model string) *FailoverError { } // Context deadline exceeded: treat as timeout, always fallback. - if err == context.DeadlineExceeded { + if errors.Is(err, context.DeadlineExceeded) { return &FailoverError{ Reason: FailoverTimeout, Provider: provider, diff --git a/pkg/providers/gemini_cli_provider.go b/pkg/providers/gemini_cli_provider.go index 2c644f378..1a974754f 100644 --- a/pkg/providers/gemini_cli_provider.go +++ b/pkg/providers/gemini_cli_provider.go @@ -63,6 +63,9 @@ func (p *GeminiCliProvider) Chat( cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("gemini cli timed out after %s: %w", p.timeout, context.DeadlineExceeded) + } stderrStr := strings.TrimSpace(stderr.String()) stdoutStr := strings.TrimSpace(stdout.String()) switch {