fix(providers): honour request_timeout for CLI providers

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 <noreply@anthropic.com>
This commit is contained in:
Eric Jacksch 2026-03-20 12:07:00 -04:00
parent 321edcfa1a
commit df967fe63c
4 changed files with 60 additions and 0 deletions

View file

@ -7,12 +7,14 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"time"
) )
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. // ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
type ClaudeCliProvider struct { type ClaudeCliProvider struct {
command string command string
workspace string workspace string
timeout time.Duration
} }
// NewClaudeCliProvider creates a new Claude CLI provider. // 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. // Chat implements LLMProvider.Chat by executing the claude CLI.
func (p *ClaudeCliProvider) Chat( func (p *ClaudeCliProvider) Chat(
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
) (*LLMResponse, error) { ) (*LLMResponse, error) {
if p.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, p.timeout)
defer cancel()
}
prompt := p.buildStdinPrompt(messages, tools) prompt := p.buildStdinPrompt(messages, tools)
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}

View file

@ -8,12 +8,14 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"time"
) )
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. // CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
type CodexCliProvider struct { type CodexCliProvider struct {
command string command string
workspace string workspace string
timeout time.Duration
} }
// NewCodexCliProvider creates a new Codex CLI provider. // 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. // Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
func (p *CodexCliProvider) Chat( func (p *CodexCliProvider) Chat(
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, 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") 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) prompt := p.buildPrompt(messages, tools)
args := []string{ args := []string{

View file

@ -180,6 +180,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
if cfg.RequestTimeout > 0 {
return NewClaudeCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil
}
return NewClaudeCliProvider(workspace), modelID, nil return NewClaudeCliProvider(workspace), modelID, nil
case "codex-cli", "codexcli": case "codex-cli", "codexcli":
@ -187,6 +190,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
if cfg.RequestTimeout > 0 {
return NewCodexCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil
}
return NewCodexCliProvider(workspace), modelID, nil return NewCodexCliProvider(workspace), modelID, nil
case "gemini-cli", "geminicli": case "gemini-cli", "geminicli":
@ -194,6 +200,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if workspace == "" { if workspace == "" {
workspace = "." workspace = "."
} }
if cfg.RequestTimeout > 0 {
return NewGeminiCliProviderWithTimeout(workspace, time.Duration(cfg.RequestTimeout)*time.Second), modelID, nil
}
return NewGeminiCliProvider(workspace), modelID, nil return NewGeminiCliProvider(workspace), modelID, nil
case "github-copilot", "copilot": case "github-copilot", "copilot":

View file

@ -7,12 +7,14 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"time"
) )
// GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess. // GeminiCliProvider implements LLMProvider using the gemini CLI as a subprocess.
type GeminiCliProvider struct { type GeminiCliProvider struct {
command string command string
workspace string workspace string
timeout time.Duration
} }
// NewGeminiCliProvider creates a new Gemini CLI provider. // 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. // Chat implements LLMProvider.Chat by executing the gemini CLI.
func (p *GeminiCliProvider) Chat( func (p *GeminiCliProvider) Chat(
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
) (*LLMResponse, error) { ) (*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 := p.buildPrompt(messages, tools)
// --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input. // --prompt "" triggers non-interactive stdin mode; the empty string is appended to stdin input.