feat(gateway): support hot reload and empty startup

- extract gateway runtime into pkg/gateway
- add gateway.hot_reload config with default and example values
- allow starting the gateway without a default model via --allow-empty
- stop treating missing enabled channels as a startup error
- update related tests
This commit is contained in:
wenjie 2026-03-17 18:33:31 +08:00
parent 11207186c8
commit ec1436d9b5
8 changed files with 138 additions and 154 deletions

View file

@ -5,6 +5,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/gateway"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -12,6 +14,7 @@ import (
func NewGatewayCommand() *cobra.Command { func NewGatewayCommand() *cobra.Command {
var debug bool var debug bool
var noTruncate bool var noTruncate bool
var allowEmpty bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "gateway", Use: "gateway",
@ -31,12 +34,19 @@ func NewGatewayCommand() *cobra.Command {
return nil return nil
}, },
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(_ *cobra.Command, _ []string) error {
return gatewayCmd(debug) return gateway.Run(debug, internal.GetConfigPath(), allowEmpty)
}, },
} }
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
cmd.Flags().BoolVarP(
&allowEmpty,
"allow-empty",
"E",
false,
"Continue starting even when no default model is configured",
)
return cmd return cmd
} }

View file

@ -28,4 +28,5 @@ func TestNewGatewayCommand(t *testing.T) {
assert.True(t, cmd.HasFlags()) assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("debug")) assert.NotNil(t, cmd.Flags().Lookup("debug"))
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
} }

View file

@ -518,6 +518,7 @@
}, },
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "port": 18790,
"hot_reload": false
} }
} }

View file

@ -357,7 +357,6 @@ func (m *Manager) StartAll(ctx context.Context) error {
if len(m.channels) == 0 { if len(m.channels) == 0 {
logger.WarnC("channels", "No channels enabled") logger.WarnC("channels", "No channels enabled")
return errors.New("no channels enabled")
} }
logger.InfoC("channels", "Starting all channels") logger.InfoC("channels", "Starting all channels")

View file

@ -625,8 +625,9 @@ func (c *ModelConfig) Validate() error {
} }
type GatewayConfig struct { type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
} }
type ToolDiscoveryConfig struct { type ToolDiscoveryConfig struct {

View file

@ -267,6 +267,9 @@ func TestDefaultConfig_Gateway(t *testing.T) {
if cfg.Gateway.Port == 0 { if cfg.Gateway.Port == 0 {
t.Error("Gateway port should have default value") t.Error("Gateway port should have default value")
} }
if cfg.Gateway.HotReload {
t.Error("Gateway hot reload should be disabled by default")
}
} }
// TestDefaultConfig_Providers verifies provider structure // TestDefaultConfig_Providers verifies provider structure

View file

@ -395,8 +395,9 @@ func DefaultConfig() *Config {
}, },
}, },
Gateway: GatewayConfig{ Gateway: GatewayConfig{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 18790, Port: 18790,
HotReload: false,
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{
MediaCleanup: MediaCleanupConfig{ MediaCleanup: MediaCleanupConfig{

View file

@ -10,7 +10,6 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
@ -42,15 +41,13 @@ import (
"github.com/sipeed/picoclaw/pkg/voice" "github.com/sipeed/picoclaw/pkg/voice"
) )
// Timeout constants for service operations
const ( const (
serviceShutdownTimeout = 30 * time.Second serviceShutdownTimeout = 30 * time.Second
providerReloadTimeout = 30 * time.Second providerReloadTimeout = 30 * time.Second
gracefulShutdownTimeout = 15 * time.Second gracefulShutdownTimeout = 15 * time.Second
) )
// gatewayServices holds references to all running services type services struct {
type gatewayServices struct {
CronService *cron.CronService CronService *cron.CronService
HeartbeatService *heartbeat.HeartbeatService HeartbeatService *heartbeat.HeartbeatService
MediaStore media.MediaStore MediaStore media.MediaStore
@ -59,24 +56,41 @@ type gatewayServices struct {
HealthServer *health.Server HealthServer *health.Server
} }
func gatewayCmd(debug bool) error { type startupBlockedProvider struct {
reason string
}
func (p *startupBlockedProvider) Chat(
_ context.Context,
_ []providers.Message,
_ []providers.ToolDefinition,
_ string,
_ map[string]any,
) (*providers.LLMResponse, error) {
return nil, fmt.Errorf("%s", p.reason)
}
func (p *startupBlockedProvider) GetDefaultModel() string {
return ""
}
// Run starts the gateway runtime using the configuration loaded from configPath.
func Run(debug bool, configPath string, allowEmptyStartup bool) error {
if debug { if debug {
logger.SetLevel(logger.DEBUG) logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
} }
configPath := internal.GetConfigPath() cfg, err := config.LoadConfig(configPath)
cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
} }
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
if err != nil { if err != nil {
return fmt.Errorf("error creating provider: %w", err) return fmt.Errorf("error creating provider: %w", err)
} }
// Use the resolved model ID from provider creation
if modelID != "" { if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID cfg.Agents.Defaults.ModelName = modelID
} }
@ -84,17 +98,13 @@ func gatewayCmd(debug bool) error {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info
fmt.Println("\n📦 Agent Status:") fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]any) toolsInfo := startupInfo["tools"].(map[string]any)
skillsInfo := startupInfo["skills"].(map[string]any) skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n", fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
skillsInfo["available"],
skillsInfo["total"])
// Log to file as well
logger.InfoCF("agent", "Agent initialized", logger.InfoCF("agent", "Agent initialized",
map[string]any{ map[string]any{
"tools_count": toolsInfo["count"], "tools_count": toolsInfo["count"],
@ -102,8 +112,7 @@ func gatewayCmd(debug bool) error {
"skills_available": skillsInfo["available"], "skills_available": skillsInfo["available"],
}) })
// Setup and start all services runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
services, err := setupAndStartServices(cfg, agentLoop, msgBus)
if err != nil { if err != nil {
return err return err
} }
@ -116,23 +125,25 @@ func gatewayCmd(debug bool) error {
go agentLoop.Run(ctx) go agentLoop.Run(ctx)
// Setup config file watcher for hot reload var configReloadChan <-chan *config.Config
configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug) stopWatch := func() {}
if cfg.Gateway.HotReload {
configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug)
logger.Info("Config hot reload enabled")
}
defer stopWatch() defer stopWatch()
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Main event loop - wait for signals or config changes
for { for {
select { select {
case <-sigChan: case <-sigChan:
logger.Info("Shutting down...") logger.Info("Shutting down...")
shutdownGateway(services, agentLoop, provider, true) shutdownGateway(runningServices, agentLoop, provider, true)
return nil return nil
case newCfg := <-configReloadChan: case newCfg := <-configReloadChan:
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus) err := handleConfigReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
if err != nil { if err != nil {
logger.Errorf("Config reload failed: %v", err) logger.Errorf("Config reload failed: %v", err)
} }
@ -140,18 +151,33 @@ func gatewayCmd(debug bool) error {
} }
} }
// setupAndStartServices initializes and starts all services func createStartupProvider(
cfg *config.Config,
allowEmptyStartup bool,
) (providers.LLMProvider, string, error) {
modelName := cfg.Agents.Defaults.GetModelName()
if modelName == "" && allowEmptyStartup {
reason := "no default model configured; gateway started in limited mode"
fmt.Printf("⚠ Warning: %s\n", reason)
logger.WarnCF("gateway", "Gateway started without default model", map[string]any{
"limited_mode": true,
})
return &startupBlockedProvider{reason: reason}, "", nil
}
return providers.CreateProvider(cfg)
}
func setupAndStartServices( func setupAndStartServices(
cfg *config.Config, cfg *config.Config,
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
) (*gatewayServices, error) { ) (*services, error) {
services := &gatewayServices{} runningServices := &services{}
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
var err error var err error
services.CronService, err = setupCronTool( runningServices.CronService, err = setupCronTool(
agentLoop, agentLoop,
msgBus, msgBus,
cfg.WorkspacePath(), cfg.WorkspacePath(),
@ -162,120 +188,105 @@ func setupAndStartServices(
if err != nil { if err != nil {
return nil, fmt.Errorf("error setting up cron service: %w", err) return nil, fmt.Errorf("error setting up cron service: %w", err)
} }
if err = services.CronService.Start(); err != nil { if err = runningServices.CronService.Start(); err != nil {
return nil, fmt.Errorf("error starting cron service: %w", err) return nil, fmt.Errorf("error starting cron service: %w", err)
} }
fmt.Println("✓ Cron service started") fmt.Println("✓ Cron service started")
// Setup heartbeat service runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
services.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Heartbeat.Interval, cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled, cfg.Heartbeat.Enabled,
) )
services.HeartbeatService.SetBus(msgBus) runningServices.HeartbeatService.SetBus(msgBus)
services.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop)) runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop))
if err = services.HeartbeatService.Start(); err != nil { if err = runningServices.HeartbeatService.Start(); err != nil {
return nil, fmt.Errorf("error starting heartbeat service: %w", err) return nil, fmt.Errorf("error starting heartbeat service: %w", err)
} }
fmt.Println("✓ Heartbeat service started") fmt.Println("✓ Heartbeat service started")
// Create media store for file lifecycle management with TTL cleanup runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled, Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
}) })
// Start the media store if it's a FileMediaStore with cleanup if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Start() fms.Start()
} }
// Create channel manager runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
if err != nil { if err != nil {
// Stop the media store if it's a FileMediaStore with cleanup if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop() fms.Stop()
} }
return nil, fmt.Errorf("error creating channel manager: %w", err) return nil, fmt.Errorf("error creating channel manager: %w", err)
} }
// Inject channel manager and media store into agent loop agentLoop.SetChannelManager(runningServices.ChannelManager)
agentLoop.SetChannelManager(services.ChannelManager) agentLoop.SetMediaStore(runningServices.MediaStore)
agentLoop.SetMediaStore(services.MediaStore)
// Wire up voice transcription if a supported provider is configured.
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
agentLoop.SetTranscriber(transcriber) agentLoop.SetTranscriber(transcriber)
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} }
enabledChannels := services.ChannelManager.GetEnabledChannels() enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else { } else {
fmt.Println("⚠ Warning: No channels enabled") fmt.Println("⚠ Warning: No channels enabled")
} }
// Setup shared HTTP server with health endpoints and webhook handlers
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
if err = services.ChannelManager.StartAll(context.Background()); err != nil { if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
return nil, fmt.Errorf("error starting channels: %w", err) return nil, fmt.Errorf("error starting channels: %w", err)
} }
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
// Setup state manager and device service
stateManager := state.NewManager(cfg.WorkspacePath()) stateManager := state.NewManager(cfg.WorkspacePath())
services.DeviceService = devices.NewService(devices.Config{ runningServices.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled, Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB, MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager) }, stateManager)
services.DeviceService.SetBus(msgBus) runningServices.DeviceService.SetBus(msgBus)
if err = services.DeviceService.Start(context.Background()); err != nil { if err = runningServices.DeviceService.Start(context.Background()); err != nil {
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()}) logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled { } else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started") fmt.Println("✓ Device event service started")
} }
return services, nil return runningServices, nil
} }
// stopAndCleanupServices stops all services and cleans up resources func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration) {
func stopAndCleanupServices(
services *gatewayServices,
shutdownTimeout time.Duration,
) {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
if services.ChannelManager != nil { if runningServices.ChannelManager != nil {
services.ChannelManager.StopAll(shutdownCtx) runningServices.ChannelManager.StopAll(shutdownCtx)
} }
if services.DeviceService != nil { if runningServices.DeviceService != nil {
services.DeviceService.Stop() runningServices.DeviceService.Stop()
} }
if services.HeartbeatService != nil { if runningServices.HeartbeatService != nil {
services.HeartbeatService.Stop() runningServices.HeartbeatService.Stop()
} }
if services.CronService != nil { if runningServices.CronService != nil {
services.CronService.Stop() runningServices.CronService.Stop()
} }
if services.MediaStore != nil { if runningServices.MediaStore != nil {
// Stop the media store if it's a FileMediaStore with cleanup if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop() fms.Stop()
} }
} }
} }
// shutdownGateway performs a complete gateway shutdown
func shutdownGateway( func shutdownGateway(
services *gatewayServices, runningServices *services,
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
provider providers.LLMProvider, provider providers.LLMProvider,
fullShutdown bool, fullShutdown bool,
@ -284,7 +295,7 @@ func shutdownGateway(
cp.Close() cp.Close()
} }
stopAndCleanupServices(services, gracefulShutdownTimeout) stopAndCleanupServices(runningServices, gracefulShutdownTimeout)
agentLoop.Stop() agentLoop.Stop()
agentLoop.Close() agentLoop.Close()
@ -292,15 +303,14 @@ func shutdownGateway(
logger.Info("✓ Gateway stopped") logger.Info("✓ Gateway stopped")
} }
// handleConfigReload handles config file reload by stopping all services,
// reloading the provider and config, and restarting services with the new config.
func handleConfigReload( func handleConfigReload(
ctx context.Context, ctx context.Context,
al *agent.AgentLoop, al *agent.AgentLoop,
newCfg *config.Config, newCfg *config.Config,
providerRef *providers.LLMProvider, providerRef *providers.LLMProvider,
services *gatewayServices, runningServices *services,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
allowEmptyStartup bool,
) error { ) error {
logger.Info("🔄 Config file changed, reloading...") logger.Info("🔄 Config file changed, reloading...")
@ -311,18 +321,14 @@ func handleConfigReload(
logger.Infof(" New model is '%s', recreating provider...", newModel) logger.Infof(" New model is '%s', recreating provider...", newModel)
// Stop all services before reloading
logger.Info(" Stopping all services...") logger.Info(" Stopping all services...")
stopAndCleanupServices(services, serviceShutdownTimeout) stopAndCleanupServices(runningServices, serviceShutdownTimeout)
// Create new provider from updated config first to ensure validity newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)
// This will use the correct API key and settings from newCfg.ModelList
newProvider, newModelID, err := providers.CreateProvider(newCfg)
if err != nil { if err != nil {
logger.Errorf(" ⚠ Error creating new provider: %v", err) logger.Errorf(" ⚠ Error creating new provider: %v", err)
logger.Warn(" Attempting to restart services with old provider and config...") logger.Warn(" Attempting to restart services with old provider and config...")
// Try to restart services with old configuration if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
} }
return fmt.Errorf("error creating new provider: %w", err) return fmt.Errorf("error creating new provider: %w", err)
@ -332,31 +338,25 @@ func handleConfigReload(
newCfg.Agents.Defaults.ModelName = newModelID newCfg.Agents.Defaults.ModelName = newModelID
} }
// Use the atomic reload method on AgentLoop to safely swap provider and config.
// This handles locking internally to prevent races with in-flight LLM calls
// and concurrent reads of registry/config while the swap occurs.
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout) reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
defer reloadCancel() defer reloadCancel()
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil { if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
logger.Errorf(" ⚠ Error reloading agent loop: %v", err) logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
// Close the newly created provider since it wasn't adopted
if cp, ok := newProvider.(providers.StatefulProvider); ok { if cp, ok := newProvider.(providers.StatefulProvider); ok {
cp.Close() cp.Close()
} }
logger.Warn(" Attempting to restart services with old provider and config...") logger.Warn(" Attempting to restart services with old provider and config...")
if restartErr := restartServices(al, services, msgBus); restartErr != nil { if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr) logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
} }
return fmt.Errorf("error reloading agent loop: %w", err) return fmt.Errorf("error reloading agent loop: %w", err)
} }
// Update local provider reference only after successful atomic reload
*providerRef = newProvider *providerRef = newProvider
// Restart all services with new config
logger.Info(" Restarting all services with new configuration...") logger.Info(" Restarting all services with new configuration...")
if err := restartServices(al, services, msgBus); err != nil { if err := restartServices(al, runningServices, msgBus); err != nil {
logger.Errorf(" ⚠ Error restarting services: %v", err) logger.Errorf(" ⚠ Error restarting services: %v", err)
return fmt.Errorf("error restarting services: %w", err) return fmt.Errorf("error restarting services: %w", err)
} }
@ -365,19 +365,16 @@ func handleConfigReload(
return nil return nil
} }
// restartServices restarts all services after a config reload
func restartServices( func restartServices(
al *agent.AgentLoop, al *agent.AgentLoop,
services *gatewayServices, runningServices *services,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
) error { ) error {
// Get current config from agent loop (which has been updated if this is a reload)
cfg := al.GetConfig() cfg := al.GetConfig()
// Re-create and start cron service with new config
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
var err error var err error
services.CronService, err = setupCronTool( runningServices.CronService, err = setupCronTool(
al, al,
msgBus, msgBus,
cfg.WorkspacePath(), cfg.WorkspacePath(),
@ -388,57 +385,51 @@ func restartServices(
if err != nil { if err != nil {
return fmt.Errorf("error restarting cron service: %w", err) return fmt.Errorf("error restarting cron service: %w", err)
} }
if err = services.CronService.Start(); err != nil { if err = runningServices.CronService.Start(); err != nil {
return fmt.Errorf("error restarting cron service: %w", err) return fmt.Errorf("error restarting cron service: %w", err)
} }
fmt.Println(" ✓ Cron service restarted") fmt.Println(" ✓ Cron service restarted")
// Re-create and start heartbeat service with new config runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
services.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Heartbeat.Interval, cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled, cfg.Heartbeat.Enabled,
) )
services.HeartbeatService.SetBus(msgBus) runningServices.HeartbeatService.SetBus(msgBus)
services.HeartbeatService.SetHandler(createHeartbeatHandler(al)) runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al))
if err = services.HeartbeatService.Start(); err != nil { if err = runningServices.HeartbeatService.Start(); err != nil {
return fmt.Errorf("error restarting heartbeat service: %w", err) return fmt.Errorf("error restarting heartbeat service: %w", err)
} }
fmt.Println(" ✓ Heartbeat service restarted") fmt.Println(" ✓ Heartbeat service restarted")
// Re-create media store with new config runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled, Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
}) })
// Start the media store if it's a FileMediaStore with cleanup if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Start() fms.Start()
} }
al.SetMediaStore(services.MediaStore) al.SetMediaStore(runningServices.MediaStore)
// Re-create channel manager with new config runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
if err != nil { if err != nil {
return fmt.Errorf("error recreating channel manager: %w", err) return fmt.Errorf("error recreating channel manager: %w", err)
} }
al.SetChannelManager(services.ChannelManager) al.SetChannelManager(runningServices.ChannelManager)
enabledChannels := services.ChannelManager.GetEnabledChannels() enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels) fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
} else { } else {
fmt.Println(" ⚠ Warning: No channels enabled") fmt.Println(" ⚠ Warning: No channels enabled")
} }
// Setup HTTP server with new config
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
// Use background context for lifecycle to ensure services persist after restartServices returns if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
if err = services.ChannelManager.StartAll(context.Background()); err != nil {
return fmt.Errorf("error restarting channels: %w", err) return fmt.Errorf("error restarting channels: %w", err)
} }
fmt.Printf( fmt.Printf(
@ -447,22 +438,20 @@ func restartServices(
cfg.Gateway.Port, cfg.Gateway.Port,
) )
// Re-create device service with new config
stateManager := state.NewManager(cfg.WorkspacePath()) stateManager := state.NewManager(cfg.WorkspacePath())
services.DeviceService = devices.NewService(devices.Config{ runningServices.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled, Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB, MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager) }, stateManager)
services.DeviceService.SetBus(msgBus) runningServices.DeviceService.SetBus(msgBus)
if err := services.DeviceService.Start(context.Background()); err != nil { if err := runningServices.DeviceService.Start(context.Background()); err != nil {
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()}) logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled { } else if cfg.Devices.Enabled {
fmt.Println(" ✓ Device event service restarted") fmt.Println(" ✓ Device event service restarted")
} }
// Wire up voice transcription with new config
transcriber := voice.DetectTranscriber(cfg) transcriber := voice.DetectTranscriber(cfg)
al.SetTranscriber(transcriber) // This will set it to nil if disabled al.SetTranscriber(transcriber)
if transcriber != nil { if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} else { } else {
@ -472,8 +461,6 @@ func restartServices(
return nil return nil
} }
// setupConfigWatcherPolling sets up a simple polling-based config file watcher
// Returns a channel for config updates and a stop function
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) { func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
configChan := make(chan *config.Config, 1) configChan := make(chan *config.Config, 1)
stop := make(chan struct{}) stop := make(chan struct{})
@ -483,11 +470,10 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
go func() { go func() {
defer wg.Done() defer wg.Done()
// Get initial file info
lastModTime := getFileModTime(configPath) lastModTime := getFileModTime(configPath)
lastSize := getFileSize(configPath) lastSize := getFileSize(configPath)
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
@ -496,20 +482,16 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
currentModTime := getFileModTime(configPath) currentModTime := getFileModTime(configPath)
currentSize := getFileSize(configPath) currentSize := getFileSize(configPath)
// Check if file changed (modification time or size changed)
if currentModTime.After(lastModTime) || currentSize != lastSize { if currentModTime.After(lastModTime) || currentSize != lastSize {
if debug { if debug {
logger.Debugf("🔍 Config file change detected") logger.Debugf("🔍 Config file change detected")
} }
// Debounce - wait a bit to ensure file write is complete
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
// Update last known state to prevent repeated reload attempts on failure
lastModTime = currentModTime lastModTime = currentModTime
lastSize = currentSize lastSize = currentSize
// Validate and load new config
newCfg, err := config.LoadConfig(configPath) newCfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
logger.Errorf("⚠ Error loading new config: %v", err) logger.Errorf("⚠ Error loading new config: %v", err)
@ -517,7 +499,6 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
continue continue
} }
// Validate the new config
if err := newCfg.ValidateModelList(); err != nil { if err := newCfg.ValidateModelList(); err != nil {
logger.Errorf(" ⚠ New config validation failed: %v", err) logger.Errorf(" ⚠ New config validation failed: %v", err)
logger.Warn(" Using previous valid config") logger.Warn(" Using previous valid config")
@ -526,15 +507,12 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
logger.Info("✓ Config file validated and loaded") logger.Info("✓ Config file validated and loaded")
// Send new config to main loop (non-blocking)
select { select {
case configChan <- newCfg: case configChan <- newCfg:
default: default:
// Channel full, skip this update
logger.Warn("⚠ Previous config reload still in progress, skipping") logger.Warn("⚠ Previous config reload still in progress, skipping")
} }
} }
case <-stop: case <-stop:
return return
} }
@ -549,7 +527,6 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
return configChan, stopFunc return configChan, stopFunc
} }
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
func getFileModTime(path string) time.Time { func getFileModTime(path string) time.Time {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@ -558,7 +535,6 @@ func getFileModTime(path string) time.Time {
return info.ModTime() return info.ModTime()
} }
// getFileSize returns the size of a file, or 0 if file doesn't exist
func getFileSize(path string) int64 { func getFileSize(path string) int64 {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@ -577,10 +553,8 @@ func setupCronTool(
) (*cron.CronService, error) { ) (*cron.CronService, error) {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json") cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil) cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool if enabled
var cronTool *tools.CronTool var cronTool *tools.CronTool
if cfg.Tools.IsToolEnabled("cron") { if cfg.Tools.IsToolEnabled("cron") {
var err error var err error
@ -592,7 +566,6 @@ func setupCronTool(
agentLoop.RegisterTool(cronTool) agentLoop.RegisterTool(cronTool)
} }
// Set onJob handler
if cronTool != nil { if cronTool != nil {
cronService.SetOnJob(func(job *cron.CronJob) (string, error) { cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job) result := cronTool.ExecuteJob(context.Background(), job)
@ -605,22 +578,17 @@ func setupCronTool(
func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {
return func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult {
// Use cli:direct as fallback if no valid channel
if channel == "" || chatID == "" { if channel == "" || chatID == "" {
channel, chatID = "cli", "direct" channel, chatID = "cli", "direct"
} }
// Use ProcessHeartbeat - no session history, each heartbeat is independent
var response string response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
var err error
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil { if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
} }
if response == "HEARTBEAT_OK" { if response == "HEARTBEAT_OK" {
return tools.SilentResult("Heartbeat OK") return tools.SilentResult("Heartbeat OK")
} }
// For heartbeat, always return silent - the subagent result will be
// sent to user via processSystemMessage when the async task completes
return tools.SilentResult(response) return tools.SilentResult(response)
} }
} }