diff --git a/CLAUDE.md b/CLAUDE.md index e9d5581..5c25a06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Pommel is a local-first semantic code search system designed to reduce context window consumption for AI coding agents. It maintains an always-current vector database of code embeddings, enabling targeted semantic searches instead of reading numerous files into context. -**Status:** v0.7.2 - Dynamic embedding dimensions and Windows path fixes +**Status:** v0.7.3 - Configurable timeouts for cold starts and slow connections ## Code Search Priority diff --git a/README.md b/README.md index e82d627..4442e34 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Local-first semantic code search for AI coding agents. [![Go Version](https://img.shields.io/github/go-mod/go-version/dbinky/Pommel)](https://go.dev/) [![License](https://img.shields.io/github/license/dbinky/Pommel)](LICENSE) -**v0.7.2** - Dynamic embedding dimensions and Windows path fixes! +**v0.7.3** - Configurable timeouts for cold starts and slow connections! Pommel maintains a vector database of your code, enabling fast semantic search without loading files into context. Designed to complement AI coding assistants by providing targeted code discovery. diff --git a/cmd/pm/main.go b/cmd/pm/main.go index 4a50e20..a1dc4b1 100644 --- a/cmd/pm/main.go +++ b/cmd/pm/main.go @@ -9,7 +9,7 @@ import ( // Set at build time via ldflags var ( - version = "0.7.2" + version = "0.7.3" commit = "unknown" date = "unknown" ) diff --git a/cmd/pommeld/main.go b/cmd/pommeld/main.go index 8ff5c06..d3b75f2 100644 --- a/cmd/pommeld/main.go +++ b/cmd/pommeld/main.go @@ -13,7 +13,7 @@ import ( ) var ( - version = "0.7.2" + version = "0.7.3" commit = "unknown" date = "unknown" ) diff --git a/internal/api/router.go b/internal/api/router.go index 2cbc3d3..f1e0cd1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3,7 +3,6 @@ package api import ( "log/slog" "net/http" - "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -25,7 +24,7 @@ func NewRouter(indexer *daemon.Indexer, cfg *config.Config, searcher Searcher) * r := chi.NewRouter() // Apply middleware - r.Use(middleware.Timeout(30 * time.Second)) + r.Use(middleware.Timeout(cfg.Timeouts.APIRequestTimeout())) r.Use(middleware.Recoverer) // Register routes diff --git a/internal/cli/client.go b/internal/cli/client.go index 1da92b7..538b758 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "time" "github.com/pommel-dev/pommel/internal/api" "github.com/pommel-dev/pommel/internal/config" @@ -43,7 +42,7 @@ func NewClient(cfg *config.Config) *Client { return &Client{ baseURL: fmt.Sprintf("http://%s", cfg.Daemon.Address()), httpClient: &http.Client{ - Timeout: 30 * time.Second, + Timeout: cfg.Timeouts.ClientRequestTimeout(), }, } } @@ -65,7 +64,7 @@ func NewClientFromProjectRoot(projectRoot string) (*Client, error) { return &Client{ baseURL: fmt.Sprintf("http://%s", cfg.Daemon.AddressWithPort(port)), httpClient: &http.Client{ - Timeout: 30 * time.Second, + Timeout: cfg.Timeouts.ClientRequestTimeout(), }, }, nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index 92c2e90..5bdda17 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -8,7 +8,7 @@ import ( ) var ( - Version = "0.7.2" + Version = "0.7.3" BuildCommit = "unknown" BuildDate = "unknown" diff --git a/internal/cli/start.go b/internal/cli/start.go index 8c2b9f4..153b2ed 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -78,7 +78,7 @@ func runStart(cmd *cobra.Command, args []string) error { address := cfg.Daemon.AddressWithPort(port) healthURL := fmt.Sprintf("http://%s/health", address) - timeout := time.After(10 * time.Second) + timeout := time.After(cfg.Timeouts.DaemonStartTimeout()) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() diff --git a/internal/cli/stop.go b/internal/cli/stop.go index daacde1..6354f04 100644 --- a/internal/cli/stop.go +++ b/internal/cli/stop.go @@ -27,6 +27,12 @@ func runStop(cmd *cobra.Command, args []string) error { return ErrNotInitialized() } + // Load config for timeout settings + cfg, err := loader.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + // Check if running stateManager := daemon.NewStateManager(projectRoot) running, pid := stateManager.IsRunning() @@ -50,7 +56,7 @@ func runStop(cmd *cobra.Command, args []string) error { } // Wait for process to exit with timeout - timeout := time.After(5 * time.Second) + timeout := time.After(cfg.Timeouts.DaemonStopTimeout()) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() diff --git a/internal/config/config.go b/internal/config/config.go index e966e84..e6533c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Embedding EmbeddingConfig `yaml:"embedding" json:"embedding" mapstructure:"embedding"` Search SearchConfig `yaml:"search" json:"search" mapstructure:"search"` Subprojects SubprojectsConfig `yaml:"subprojects" json:"subprojects" mapstructure:"subprojects"` + Timeouts TimeoutsConfig `yaml:"timeouts" json:"timeouts" mapstructure:"timeouts"` } // WatcherConfig contains file watcher settings @@ -164,6 +165,93 @@ type SubprojectsConfig struct { Exclude []string `yaml:"exclude" json:"exclude,omitempty" mapstructure:"exclude"` } +// TimeoutsConfig contains timeout settings for various operations. +// All timeout values are in milliseconds. +// Longer timeouts are useful when embedding models need to be loaded from disk +// (cold start), which can take significantly longer than when the model is already in memory. +type TimeoutsConfig struct { + // EmbeddingRequestMs is the timeout for embedding API requests (default: 120000 = 2 minutes). + // This should be long enough to allow for model loading on cold starts. + EmbeddingRequestMs int `yaml:"embedding_request_ms" json:"embedding_request_ms" mapstructure:"embedding_request_ms"` + + // DaemonStartMs is the timeout for waiting for the daemon to start (default: 30000 = 30 seconds). + DaemonStartMs int `yaml:"daemon_start_ms" json:"daemon_start_ms" mapstructure:"daemon_start_ms"` + + // DaemonStopMs is the timeout for waiting for the daemon to stop (default: 10000 = 10 seconds). + DaemonStopMs int `yaml:"daemon_stop_ms" json:"daemon_stop_ms" mapstructure:"daemon_stop_ms"` + + // ClientRequestMs is the timeout for CLI client requests to the daemon (default: 120000 = 2 minutes). + // This should be long enough to handle search requests that trigger embedding generation. + ClientRequestMs int `yaml:"client_request_ms" json:"client_request_ms" mapstructure:"client_request_ms"` + + // APIRequestMs is the timeout for API middleware (default: 120000 = 2 minutes). + APIRequestMs int `yaml:"api_request_ms" json:"api_request_ms" mapstructure:"api_request_ms"` + + // ShutdownMs is the timeout for graceful daemon shutdown (default: 10000 = 10 seconds). + ShutdownMs int `yaml:"shutdown_ms" json:"shutdown_ms" mapstructure:"shutdown_ms"` +} + +// DefaultTimeoutsConfig returns the default timeout configuration. +// Defaults are set high enough to handle cold starts where models need to be loaded. +func DefaultTimeoutsConfig() TimeoutsConfig { + return TimeoutsConfig{ + EmbeddingRequestMs: 120000, // 2 minutes - allows for model loading + DaemonStartMs: 30000, // 30 seconds + DaemonStopMs: 10000, // 10 seconds + ClientRequestMs: 120000, // 2 minutes - allows for embedding generation + APIRequestMs: 120000, // 2 minutes + ShutdownMs: 10000, // 10 seconds + } +} + +// EmbeddingRequestTimeout returns the embedding request timeout as time.Duration. +func (t TimeoutsConfig) EmbeddingRequestTimeout() time.Duration { + if t.EmbeddingRequestMs <= 0 { + return DefaultTimeoutsConfig().EmbeddingRequestTimeout() + } + return time.Duration(t.EmbeddingRequestMs) * time.Millisecond +} + +// DaemonStartTimeout returns the daemon start timeout as time.Duration. +func (t TimeoutsConfig) DaemonStartTimeout() time.Duration { + if t.DaemonStartMs <= 0 { + return DefaultTimeoutsConfig().DaemonStartTimeout() + } + return time.Duration(t.DaemonStartMs) * time.Millisecond +} + +// DaemonStopTimeout returns the daemon stop timeout as time.Duration. +func (t TimeoutsConfig) DaemonStopTimeout() time.Duration { + if t.DaemonStopMs <= 0 { + return DefaultTimeoutsConfig().DaemonStopTimeout() + } + return time.Duration(t.DaemonStopMs) * time.Millisecond +} + +// ClientRequestTimeout returns the client request timeout as time.Duration. +func (t TimeoutsConfig) ClientRequestTimeout() time.Duration { + if t.ClientRequestMs <= 0 { + return DefaultTimeoutsConfig().ClientRequestTimeout() + } + return time.Duration(t.ClientRequestMs) * time.Millisecond +} + +// APIRequestTimeout returns the API request timeout as time.Duration. +func (t TimeoutsConfig) APIRequestTimeout() time.Duration { + if t.APIRequestMs <= 0 { + return DefaultTimeoutsConfig().APIRequestTimeout() + } + return time.Duration(t.APIRequestMs) * time.Millisecond +} + +// ShutdownTimeout returns the shutdown timeout as time.Duration. +func (t TimeoutsConfig) ShutdownTimeout() time.Duration { + if t.ShutdownMs <= 0 { + return DefaultTimeoutsConfig().ShutdownTimeout() + } + return time.Duration(t.ShutdownMs) * time.Millisecond +} + // ProjectOverride defines a manual sub-project configuration type ProjectOverride struct { ID string `yaml:"id" json:"id,omitempty" mapstructure:"id"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 913dca3..cbd17e8 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -81,5 +81,6 @@ func Default() *Config { Projects: nil, Exclude: nil, }, + Timeouts: DefaultTimeoutsConfig(), } } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index b1790cf..6d8715f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -118,6 +118,7 @@ func New(projectRoot string, cfg *config.Config, logger *slog.Logger) (*Daemon, // Build provider config from embedding settings (needed before db.Open for dimensions) providerCfg := &embedder.ProviderConfig{ Provider: cfg.Embedding.Provider, + Timeout: cfg.Timeouts.EmbeddingRequestTimeout(), Ollama: embedder.OllamaProviderSettings{ URL: cfg.Embedding.GetOllamaURL(), Model: cfg.Embedding.Ollama.Model, @@ -504,7 +505,7 @@ func (d *Daemon) shutdown() error { // Shutdown API server if d.server != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), d.config.Timeouts.ShutdownTimeout()) defer cancel() if err := d.server.Shutdown(shutdownCtx); err != nil { d.logger.Warn("server shutdown error", "error", err) diff --git a/internal/embedder/provider.go b/internal/embedder/provider.go index 18cf60f..a1f792d 100644 --- a/internal/embedder/provider.go +++ b/internal/embedder/provider.go @@ -1,6 +1,9 @@ package embedder -import "fmt" +import ( + "fmt" + "time" +) // ProviderType represents the type of embedding provider type ProviderType string @@ -100,6 +103,7 @@ func APIProviders() []ProviderType { // ProviderConfig holds the configuration for creating an embedder type ProviderConfig struct { Provider string + Timeout time.Duration // Timeout for embedding requests Ollama OllamaProviderSettings OpenAI OpenAIProviderSettings Voyage VoyageProviderSettings @@ -135,6 +139,7 @@ func NewFromConfig(cfg *ProviderConfig) (Embedder, error) { return NewOllamaClient(OllamaConfig{ BaseURL: cfg.Ollama.URL, Model: cfg.Ollama.Model, + Timeout: cfg.Timeout, }), nil case ProviderOpenAI: @@ -146,8 +151,9 @@ func NewFromConfig(cfg *ProviderConfig) (Embedder, error) { } } return NewOpenAIClient(OpenAIConfig{ - APIKey: cfg.OpenAI.APIKey, - Model: cfg.OpenAI.Model, + APIKey: cfg.OpenAI.APIKey, + Model: cfg.OpenAI.Model, + Timeout: cfg.Timeout, }), nil case ProviderVoyage: @@ -159,8 +165,9 @@ func NewFromConfig(cfg *ProviderConfig) (Embedder, error) { } } return NewVoyageClient(VoyageConfig{ - APIKey: cfg.Voyage.APIKey, - Model: cfg.Voyage.Model, + APIKey: cfg.Voyage.APIKey, + Model: cfg.Voyage.Model, + Timeout: cfg.Timeout, }), nil default: