fix(channel-only): align startup checks and docs with gateway behavior
Keep shared HTTP and reload behavior consistent with gateway expectations, harden channel-only startup status handling, start the ASR voice agent only after channels are up, and clarify runtime logs, tests, and docs for channel-only mode.
This commit is contained in:
parent
1ea072b483
commit
4bcd21f4cc
9 changed files with 227 additions and 66 deletions
|
|
@ -5,6 +5,8 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewStartCommand(t *testing.T) {
|
func TestNewStartCommand(t *testing.T) {
|
||||||
|
|
@ -40,5 +42,6 @@ func TestStartCommandPreRunE_NoTruncateWithDebug(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = cmd.PreRunE(cmd, nil)
|
err = cmd.PreRunE(cmd, nil)
|
||||||
|
defer utils.SetDisableTruncation(false)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ Use `picoclaw channel start` when you want to run enabled channels and AgentLoop
|
||||||
# Start all enabled channels from config.json
|
# Start all enabled channels from config.json
|
||||||
picoclaw channel start
|
picoclaw channel start
|
||||||
|
|
||||||
# Same startup checks as gateway command
|
# Shares default-model/provider validation and --allow-empty behavior with gateway
|
||||||
picoclaw channel start --allow-empty
|
picoclaw channel start --allow-empty
|
||||||
picoclaw channel start --debug
|
picoclaw channel start --debug
|
||||||
```
|
```
|
||||||
|
|
@ -49,9 +49,11 @@ picoclaw channel start --debug
|
||||||
Compared with `picoclaw gateway`, channel-only runtime:
|
Compared with `picoclaw gateway`, channel-only runtime:
|
||||||
|
|
||||||
- Keeps MessageBus + AgentLoop + ChannelManager (full chat processing path)
|
- Keeps MessageBus + AgentLoop + ChannelManager (full chat processing path)
|
||||||
- Starts shared HTTP server for channel webhooks and `/health`/`/ready`
|
- Attempts to start shared HTTP server for channel webhooks and `/health`/`/ready` (best-effort; for loopback hosts, fallback remains loopback-only and may be disabled if binding fails)
|
||||||
- Does **not** start Cron service, Heartbeat service, Device service
|
- Does **not** start Cron service, Heartbeat service, Device service
|
||||||
- Does **not** enable config hot reload or `/reload`
|
- Does **not** enable config hot reload; `/reload` remains exposed by the shared health server but returns 503 (`reload not configured`) in channel-only mode
|
||||||
|
|
||||||
|
Note: Because shared HTTP startup is best-effort, webhook-based channels may be unavailable when no usable host/port can be bound.
|
||||||
|
|
||||||
Validation note: channel-only runtime is channel-agnostic by design. Current runtime validation is primarily focused on QQ, while other channels are continuously validated.
|
Validation note: channel-only runtime is channel-agnostic by design. Current runtime validation is primarily focused on QQ, while other channels are continuously validated.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gat
|
||||||
# 启动 config.json 中所有已启用 channel
|
# 启动 config.json 中所有已启用 channel
|
||||||
picoclaw channel start
|
picoclaw channel start
|
||||||
|
|
||||||
# 与 gateway 启动校验保持一致
|
# 与 gateway 启动时的默认模型/Provider 校验及 --allow-empty 行为保持一致
|
||||||
picoclaw channel start --allow-empty
|
picoclaw channel start --allow-empty
|
||||||
picoclaw channel start --debug
|
picoclaw channel start --debug
|
||||||
```
|
```
|
||||||
|
|
@ -47,9 +47,9 @@ picoclaw channel start --debug
|
||||||
与 `picoclaw gateway` 相比,channel 独立运行模式:
|
与 `picoclaw gateway` 相比,channel 独立运行模式:
|
||||||
|
|
||||||
- 保留 MessageBus + AgentLoop + ChannelManager(完整消息处理链路)
|
- 保留 MessageBus + AgentLoop + ChannelManager(完整消息处理链路)
|
||||||
- 启动共享 HTTP 服务,用于 channel webhook 与 `/health`/`/ready`
|
- 尝试启动共享 HTTP 服务,用于 channel webhook 与 `/health`/`/ready`(best-effort):若配置的 host/port 无法绑定,则会禁用共享 HTTP(当配置为 loopback 时,回退也仅限 loopback,例如 `::1` 或 `127.0.0.1`),此时 webhook 类 channel 以及 `/health`、`/ready` 将不可用
|
||||||
- **不会** 启动 Cron、Heartbeat、Device 服务
|
- **不会** 启动 Cron、Heartbeat、Device 服务
|
||||||
- **不会** 启用配置热更新和 `/reload`
|
- **不会** 启用配置热更新;channel-only 模式下 `/reload` 仍可访问,但会返回 503(`reload not configured`)
|
||||||
|
|
||||||
验证说明:channel 独立运行模式在设计上对各渠道通用。当前运行时验证主要覆盖 QQ,其他渠道仍在持续验证中。
|
验证说明:channel 独立运行模式在设计上对各渠道通用。当前运行时验证主要覆盖 QQ,其他渠道仍在持续验证中。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ type Manager struct {
|
||||||
dispatchTask *asyncTask
|
dispatchTask *asyncTask
|
||||||
mux *dynamicServeMux
|
mux *dynamicServeMux
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
|
httpServerErrors chan error
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
placeholders sync.Map // "channel:chatID" → placeholderID (string)
|
placeholders sync.Map // "channel:chatID" → placeholderID (string)
|
||||||
typingStops sync.Map // "channel:chatID" → func()
|
typingStops sync.Map // "channel:chatID" → func()
|
||||||
|
|
@ -452,6 +453,7 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
m.httpServerErrors = make(chan error, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerHTTPHandlersLocked registers webhook and health-check handlers for
|
// registerHTTPHandlersLocked registers webhook and health-check handlers for
|
||||||
|
|
@ -541,12 +543,18 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
|
|
||||||
// Start shared HTTP server if configured
|
// Start shared HTTP server if configured
|
||||||
if m.httpServer != nil {
|
if m.httpServer != nil {
|
||||||
|
httpServer := m.httpServer
|
||||||
|
httpServerErrors := m.httpServerErrors
|
||||||
go func() {
|
go func() {
|
||||||
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
||||||
"addr": m.httpServer.Addr,
|
"addr": httpServer.Addr,
|
||||||
})
|
})
|
||||||
if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.FatalCF("channels", "Shared HTTP server error", map[string]any{
|
select {
|
||||||
|
case httpServerErrors <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -557,6 +565,12 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) HTTPServerErrors() <-chan error {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.httpServerErrors
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) StopAll(ctx context.Context) error {
|
func (m *Manager) StopAll(ctx context.Context) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
@ -573,6 +587,7 @@ func (m *Manager) StopAll(ctx context.Context) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
m.httpServer = nil
|
m.httpServer = nil
|
||||||
|
m.httpServerErrors = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel dispatcher
|
// Cancel dispatcher
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,10 @@ type channelServices struct {
|
||||||
MediaStore media.MediaStore
|
MediaStore media.MediaStore
|
||||||
ChannelManager *channels.Manager
|
ChannelManager *channels.Manager
|
||||||
HealthServer *health.Server
|
HealthServer *health.Server
|
||||||
|
VoiceAgentCancel context.CancelFunc
|
||||||
ListenHost string
|
ListenHost string
|
||||||
ListenPort int
|
ListenPort int
|
||||||
|
ListenAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunChannelsOnly starts channel and agent loop runtime without gateway side services.
|
// RunChannelsOnly starts channel and agent loop runtime without gateway side services.
|
||||||
|
|
@ -46,7 +48,7 @@ func RunChannelsOnly(debug bool, homePath, configPath string, allowEmptyStartup
|
||||||
defer panicFunc()
|
defer panicFunc()
|
||||||
|
|
||||||
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, channelLogFile)); err != nil {
|
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, channelLogFile)); err != nil {
|
||||||
panic(fmt.Sprintf("error enabling file logging: %v", err))
|
return fmt.Errorf("error enabling file logging: %w", err)
|
||||||
}
|
}
|
||||||
defer logger.DisableFileLogging()
|
defer logger.DisableFileLogging()
|
||||||
|
|
||||||
|
|
@ -55,21 +57,24 @@ func RunChannelsOnly(debug bool, homePath, configPath string, allowEmptyStartup
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
if err = preCheckConfig(cfg); err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
|
} else {
|
||||||
|
effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
|
||||||
|
logger.SetLevelFromString(effectiveLogLevel)
|
||||||
|
logger.Infof("Log level set to %q", effectiveLogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
provider, modelID, err := createStartupProviderForRuntime(cfg, allowEmptyStartup, "channel-only runtime")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating provider: %w", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowEmptyStartup {
|
|
||||||
fmt.Println(" ⚠ Channel-only runtime started in limited mode")
|
|
||||||
}
|
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.ModelName = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
@ -79,16 +84,36 @@ func RunChannelsOnly(debug bool, homePath, configPath string, allowEmptyStartup
|
||||||
|
|
||||||
fmt.Println("\n📦 Agent Status:")
|
fmt.Println("\n📦 Agent Status:")
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
|
||||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
var toolsCount int
|
||||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
if toolsRaw, ok := startupInfo["tools"].(map[string]any); ok && toolsRaw != nil {
|
||||||
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
|
if v, ok := toolsRaw["count"].(int); ok {
|
||||||
|
toolsCount = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var skillsAvailable, skillsTotal int
|
||||||
|
if skillsRaw, ok := startupInfo["skills"].(map[string]any); ok && skillsRaw != nil {
|
||||||
|
if v, ok := skillsRaw["available"].(int); ok {
|
||||||
|
skillsAvailable = v
|
||||||
|
}
|
||||||
|
if v, ok := skillsRaw["total"].(int); ok {
|
||||||
|
skillsTotal = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolsCount == 0 && skillsAvailable == 0 && skillsTotal == 0 {
|
||||||
|
fmt.Println(" • Agent startup info not available")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" • Tools: %d loaded\n", toolsCount)
|
||||||
|
fmt.Printf(" • Skills: %d/%d available\n", skillsAvailable, skillsTotal)
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Agent initialized",
|
logger.InfoCF("agent", "Agent initialized",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tools_count": toolsInfo["count"],
|
"tools_count": toolsCount,
|
||||||
"skills_total": skillsInfo["total"],
|
"skills_total": skillsTotal,
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsAvailable,
|
||||||
})
|
})
|
||||||
|
|
||||||
runningServices, err := setupAndStartChannelServices(cfg, agentLoop, msgBus)
|
runningServices, err := setupAndStartChannelServices(cfg, agentLoop, msgBus)
|
||||||
|
|
@ -96,8 +121,15 @@ func RunChannelsOnly(debug bool, homePath, configPath string, allowEmptyStartup
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if runningServices.ListenHost != "" {
|
if runningServices.HealthServer != nil {
|
||||||
fmt.Printf("✓ Channel runtime started on %s:%d\n", runningServices.ListenHost, runningServices.ListenPort)
|
if runningServices.ListenHost == "" {
|
||||||
|
fmt.Printf(
|
||||||
|
"✓ Channel runtime started (shared HTTP server enabled on all interfaces, port %d)\n",
|
||||||
|
runningServices.ListenPort,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Channel runtime started on %s\n", runningServices.ListenAddr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("✓ Channel runtime started (shared HTTP server disabled)")
|
fmt.Println("✓ Channel runtime started (shared HTTP server disabled)")
|
||||||
}
|
}
|
||||||
|
|
@ -108,13 +140,20 @@ func RunChannelsOnly(debug bool, homePath, configPath string, allowEmptyStartup
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
go agentLoop.Run(ctx)
|
||||||
|
|
||||||
|
httpErrCh := runningServices.ChannelManager.HTTPServerErrors()
|
||||||
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)
|
||||||
|
|
||||||
<-sigChan
|
select {
|
||||||
|
case <-sigChan:
|
||||||
logger.Info("Shutting down channel runtime...")
|
logger.Info("Shutting down channel runtime...")
|
||||||
shutdownChannelRuntime(runningServices, agentLoop, provider)
|
shutdownChannelRuntime(runningServices, agentLoop, provider)
|
||||||
return nil
|
return nil
|
||||||
|
case err := <-httpErrCh:
|
||||||
|
logger.Errorf("Shared HTTP server stopped in channel-only mode: %v", err)
|
||||||
|
shutdownChannelRuntime(runningServices, agentLoop, provider)
|
||||||
|
return fmt.Errorf("shared HTTP server failed: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupAndStartChannelServices(
|
func setupAndStartChannelServices(
|
||||||
|
|
@ -145,7 +184,9 @@ func setupAndStartChannelServices(
|
||||||
agentLoop.SetChannelManager(runningServices.ChannelManager)
|
agentLoop.SetChannelManager(runningServices.ChannelManager)
|
||||||
agentLoop.SetMediaStore(runningServices.MediaStore)
|
agentLoop.SetMediaStore(runningServices.MediaStore)
|
||||||
|
|
||||||
if transcriber := asr.DetectTranscriber(cfg); transcriber != nil {
|
var transcriber asr.Transcriber
|
||||||
|
transcriber = asr.DetectTranscriber(cfg)
|
||||||
|
if 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()})
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +209,7 @@ func setupAndStartChannelServices(
|
||||||
addr := net.JoinHostPort(listenHost, strconv.Itoa(cfg.Gateway.Port))
|
addr := net.JoinHostPort(listenHost, strconv.Itoa(cfg.Gateway.Port))
|
||||||
runningServices.ListenHost = listenHost
|
runningServices.ListenHost = listenHost
|
||||||
runningServices.ListenPort = cfg.Gateway.Port
|
runningServices.ListenPort = cfg.Gateway.Port
|
||||||
|
runningServices.ListenAddr = addr
|
||||||
runningServices.HealthServer = health.NewServer(listenHost, cfg.Gateway.Port, "")
|
runningServices.HealthServer = health.NewServer(listenHost, cfg.Gateway.Port, "")
|
||||||
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
||||||
}
|
}
|
||||||
|
|
@ -179,12 +221,29 @@ func setupAndStartChannelServices(
|
||||||
return nil, fmt.Errorf("error starting channels: %w", err)
|
return nil, fmt.Errorf("error starting channels: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if runningServices.ListenHost != "" {
|
if transcriber != nil {
|
||||||
|
vaCtx, vaCancel := context.WithCancel(context.Background())
|
||||||
|
runningServices.VoiceAgentCancel = vaCancel
|
||||||
|
voiceAgent := asr.NewAgent(msgBus, transcriber)
|
||||||
|
voiceAgent.Start(vaCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runningServices.HealthServer != nil {
|
||||||
|
runningServices.HealthServer.SetReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runningServices.HealthServer != nil {
|
||||||
|
if runningServices.ListenHost == "" {
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"✓ Health endpoints available at http://%s:%d/health and /ready\n",
|
"✓ Health endpoints available on all interfaces at port %d (/health and /ready; /reload returns 503 when not configured)\n",
|
||||||
runningServices.ListenHost,
|
|
||||||
runningServices.ListenPort,
|
runningServices.ListenPort,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(
|
||||||
|
"✓ Health endpoints available at http://%s/health and /ready (/reload returns 503 when not configured)\n",
|
||||||
|
runningServices.ListenAddr,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("⚠ Shared HTTP server disabled; /health and webhook endpoints are unavailable")
|
fmt.Println("⚠ Shared HTTP server disabled; /health and webhook endpoints are unavailable")
|
||||||
}
|
}
|
||||||
|
|
@ -196,24 +255,40 @@ func resolveChannelOnlyListenHost(host string, port int) (string, error) {
|
||||||
if err := probeTCPBind(host, port); err == nil {
|
if err := probeTCPBind(host, port); err == nil {
|
||||||
return host, nil
|
return host, nil
|
||||||
} else if isLoopbackHost(host) {
|
} else if isLoopbackHost(host) {
|
||||||
fallbackErr := probeTCPBind("0.0.0.0", port)
|
// Keep loopback scope when fallback is needed to avoid widening exposure.
|
||||||
if fallbackErr == nil {
|
ipv6FallbackErr := probeTCPBind("::1", port)
|
||||||
|
if ipv6FallbackErr == nil {
|
||||||
logger.WarnCF(
|
logger.WarnCF(
|
||||||
"channels",
|
"channels",
|
||||||
"Loopback host unavailable in channel-only mode, fallback to wildcard",
|
"Loopback host unavailable in channel-only mode, fallback to IPv6 loopback",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return "0.0.0.0", nil
|
return "::1", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ipv4FallbackErr := probeTCPBind("127.0.0.1", port)
|
||||||
|
if ipv4FallbackErr == nil {
|
||||||
|
logger.WarnCF(
|
||||||
|
"channels",
|
||||||
|
"Loopback host unavailable in channel-only mode, fallback to IPv4 loopback",
|
||||||
|
map[string]any{
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return "127.0.0.1", nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"bind fallback 0.0.0.0:%d failed after %s:%d failed: %w (original error: %v)",
|
"bind fallback [::1]:%d and 127.0.0.1:%d failed after %s:%d failed: ipv6 error: %v; ipv4 error: %v; original error: %v",
|
||||||
|
port,
|
||||||
port,
|
port,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
fallbackErr,
|
ipv6FallbackErr,
|
||||||
|
ipv4FallbackErr,
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -250,6 +325,14 @@ func shutdownChannelRuntime(
|
||||||
}
|
}
|
||||||
|
|
||||||
if runningServices != nil {
|
if runningServices != nil {
|
||||||
|
if runningServices.HealthServer != nil {
|
||||||
|
runningServices.HealthServer.SetReady(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runningServices.VoiceAgentCancel != nil {
|
||||||
|
runningServices.VoiceAgentCancel()
|
||||||
|
}
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
|
||||||
13
pkg/gateway/channel_only_test.go
Normal file
13
pkg/gateway/channel_only_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveChannelOnlyListenHostRejectsNonLoopbackFallback(t *testing.T) {
|
||||||
|
host, err := resolveChannelOnlyListenHost("256.256.256.256", 1)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid non-loopback host")
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
t.Fatalf("host = %q, want empty", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -288,12 +288,26 @@ func executeReload(
|
||||||
func createStartupProvider(
|
func createStartupProvider(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
allowEmptyStartup bool,
|
allowEmptyStartup bool,
|
||||||
|
) (providers.LLMProvider, string, error) {
|
||||||
|
return createStartupProviderForRuntime(cfg, allowEmptyStartup, "gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createStartupProviderForRuntime(
|
||||||
|
cfg *config.Config,
|
||||||
|
allowEmptyStartup bool,
|
||||||
|
runtimeName string,
|
||||||
) (providers.LLMProvider, string, error) {
|
) (providers.LLMProvider, string, error) {
|
||||||
modelName := cfg.Agents.Defaults.GetModelName()
|
modelName := cfg.Agents.Defaults.GetModelName()
|
||||||
if modelName == "" && allowEmptyStartup {
|
if modelName == "" && allowEmptyStartup {
|
||||||
reason := "no default model configured; gateway started in limited mode"
|
runtimeName = strings.TrimSpace(runtimeName)
|
||||||
|
if runtimeName == "" {
|
||||||
|
runtimeName = "gateway"
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := fmt.Sprintf("no default model configured; %s started in limited mode", runtimeName)
|
||||||
fmt.Printf("⚠ Warning: %s\n", reason)
|
fmt.Printf("⚠ Warning: %s\n", reason)
|
||||||
logger.WarnCF("gateway", "Gateway started without default model", map[string]any{
|
logger.WarnCF("runtime", "Runtime started without default model", map[string]any{
|
||||||
|
"runtime": runtimeName,
|
||||||
"limited_mode": true,
|
"limited_mode": true,
|
||||||
})
|
})
|
||||||
return &startupBlockedProvider{reason: reason}, "", nil
|
return &startupBlockedProvider{reason: reason}, "", nil
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ type HandlerMux interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.
|
// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.
|
||||||
// This allows the health endpoints to be served by a shared HTTP server.
|
// The /reload handler checks reload availability at request time.
|
||||||
func (s *Server) RegisterOnMux(mux HandlerMux) {
|
func (s *Server) RegisterOnMux(mux HandlerMux) {
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ func newTestServer() *Server {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newReloadEnabledTestServer() *Server {
|
||||||
|
s := newTestServer()
|
||||||
|
s.SetReloadFunc(func() error { return nil })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func TestHealthHandler_ReturnsOK(t *testing.T) {
|
func TestHealthHandler_ReturnsOK(t *testing.T) {
|
||||||
s := newTestServer()
|
s := newTestServer()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
|
@ -150,7 +156,7 @@ func TestReadyHandler_PassingCheck(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReloadHandler_MethodNotAllowed(t *testing.T) {
|
func TestReloadHandler_MethodNotAllowed(t *testing.T) {
|
||||||
s := newTestServer()
|
s := newReloadEnabledTestServer()
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/reload", nil)
|
req := httptest.NewRequest(http.MethodGet, "/reload", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -177,7 +183,7 @@ func TestReloadHandler_NoReloadFunc(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReloadHandler_Success(t *testing.T) {
|
func TestReloadHandler_Success(t *testing.T) {
|
||||||
s := newTestServer()
|
s := newReloadEnabledTestServer()
|
||||||
called := false
|
called := false
|
||||||
s.SetReloadFunc(func() error {
|
s.SetReloadFunc(func() error {
|
||||||
called = true
|
called = true
|
||||||
|
|
@ -199,7 +205,7 @@ func TestReloadHandler_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReloadHandler_Error(t *testing.T) {
|
func TestReloadHandler_Error(t *testing.T) {
|
||||||
s := newTestServer()
|
s := newReloadEnabledTestServer()
|
||||||
s.SetReloadFunc(func() error {
|
s.SetReloadFunc(func() error {
|
||||||
return errors.New("config parse error")
|
return errors.New("config parse error")
|
||||||
})
|
})
|
||||||
|
|
@ -290,6 +296,31 @@ func TestRegisterOnMux(t *testing.T) {
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Errorf("/ready on custom mux = %d, want %d", w.Code, http.StatusOK)
|
t.Errorf("/ready on custom mux = %d, want %d", w.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/reload", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer test")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("/reload on mux without reload = %d, want %d", w.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterOnMux_WithReload(t *testing.T) {
|
||||||
|
s := newReloadEnabledTestServer()
|
||||||
|
s.SetReady(true)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
s.RegisterOnMux(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/reload", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer test")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("/reload on mux with reload = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewServer(t *testing.T) {
|
func TestNewServer(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue