fix(launcher): detect and display externally-managed gateway as running

TryAutoStartGateway only checked gateway.cmd, which tracks processes the
launcher itself spawned. A gateway managed externally (e.g. via systemd)
was invisible to this check, causing two problems:
  1. The launcher started a duplicate gateway instance on every launch.
  2. The WebUI showed "Gateway Not Running" even when it was healthy.

Fix: probe the gateway health endpoint in two places:
  - TryAutoStartGateway: skip auto-start if the health endpoint responds.
  - gatewayStatusData: report "running" when the launcher has no owned
    process but the health endpoint is responding. Launcher-owned
    transition states (restarting/error) take precedence over the probe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Jacksch 2026-03-19 14:50:38 -04:00
parent 249a63be09
commit afa7fe3f69

View file

@ -72,6 +72,13 @@ func (h *Handler) TryAutoStartGateway() {
gateway.cmd = nil
}
// Check whether a gateway is already running externally (e.g. via systemd).
// If the health endpoint responds we skip auto-start to avoid a duplicate.
if h.isGatewayHealthy() {
log.Printf("Skip auto-starting gateway: already running externally")
return
}
ready, reason, err := h.gatewayStartReady()
if err != nil {
log.Printf("Skip auto-starting gateway: %v", err)
@ -117,6 +124,27 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
return true, "", nil
}
// isGatewayHealthy probes the gateway health endpoint and returns true if it
// responds with HTTP 200. Used to detect an externally-managed gateway process.
func (h *Handler) isGatewayHealthy() bool {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return false
}
host := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
port := cfg.Gateway.Port
if port == 0 {
port = 18790
}
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
resp, err := gatewayHealthGet(url, 1*time.Second)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig {
modelCfg, err := cfg.GetModelConfig(modelName)
if err != nil {
@ -596,8 +624,15 @@ func (h *Handler) gatewayStatusData() map[string]any {
if !processAlive {
gateway.mu.Lock()
data["gateway_status"] = currentGatewayStatusLocked(false)
launcherStatus := currentGatewayStatusLocked(false)
gateway.mu.Unlock()
// Only probe for an external gateway when the launcher has no in-progress
// state (restarting/error). Those states take precedence.
if launcherStatus == "stopped" && h.isGatewayHealthy() {
data["gateway_status"] = "running"
} else {
data["gateway_status"] = launcherStatus
}
} else {
// Process is alive — probe its health endpoint
host := "127.0.0.1"