fix: unified restart required (#1978)

Unified restart-required detection and notification mechanism so that model, tool, and configuration changes all follow the same signature-based comparison logic.
This commit is contained in:
肆月 2026-03-28 22:13:50 +08:00 committed by GitHub
parent 1809d04905
commit 27f638e909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 295 additions and 18 deletions

View file

@ -29,6 +29,7 @@ var gateway = struct {
cmd *exec.Cmd cmd *exec.Cmd
owned bool // true if we started the process, false if we attached to an existing one owned bool // true if we started the process, false if we attached to an existing one
bootDefaultModel string bootDefaultModel string
bootConfigSignature string
runtimeStatus string runtimeStatus string
startupDeadline time.Time startupDeadline time.Time
logs *LogBuffer logs *LogBuffer
@ -177,14 +178,93 @@ func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig
return modelCfg return modelCfg
} }
func gatewayRestartRequired(configDefaultModel, bootDefaultModel, gatewayStatus string) bool { func computeConfigSignature(cfg *config.Config) string {
if cfg == nil {
return ""
}
var parts []string
defaultModel := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
if defaultModel != "" {
parts = append(parts, "model:"+defaultModel)
}
toolSignatures := []string{}
if cfg.Tools.ReadFile.Enabled {
toolSignatures = append(toolSignatures, "read_file")
}
if cfg.Tools.WriteFile.Enabled {
toolSignatures = append(toolSignatures, "write_file")
}
if cfg.Tools.ListDir.Enabled {
toolSignatures = append(toolSignatures, "list_dir")
}
if cfg.Tools.EditFile.Enabled {
toolSignatures = append(toolSignatures, "edit_file")
}
if cfg.Tools.AppendFile.Enabled {
toolSignatures = append(toolSignatures, "append_file")
}
if cfg.Tools.Exec.Enabled {
toolSignatures = append(toolSignatures, "exec")
}
if cfg.Tools.Cron.Enabled {
toolSignatures = append(toolSignatures, "cron")
}
if cfg.Tools.Web.Enabled {
toolSignatures = append(toolSignatures, "web")
}
if cfg.Tools.WebFetch.Enabled {
toolSignatures = append(toolSignatures, "web_fetch")
}
if cfg.Tools.Message.Enabled {
toolSignatures = append(toolSignatures, "message")
}
if cfg.Tools.SendFile.Enabled {
toolSignatures = append(toolSignatures, "send_file")
}
if cfg.Tools.FindSkills.Enabled {
toolSignatures = append(toolSignatures, "find_skills")
}
if cfg.Tools.InstallSkill.Enabled {
toolSignatures = append(toolSignatures, "install_skill")
}
if cfg.Tools.Spawn.Enabled {
toolSignatures = append(toolSignatures, "spawn")
}
if cfg.Tools.SpawnStatus.Enabled {
toolSignatures = append(toolSignatures, "spawn_status")
}
if cfg.Tools.I2C.Enabled {
toolSignatures = append(toolSignatures, "i2c")
}
if cfg.Tools.SPI.Enabled {
toolSignatures = append(toolSignatures, "spi")
}
if cfg.Tools.MCP.Enabled {
toolSignatures = append(toolSignatures, "mcp")
}
if cfg.Tools.MCP.Discovery.Enabled {
toolSignatures = append(toolSignatures, "mcp_discovery")
}
if cfg.Tools.MCP.Discovery.UseRegex {
toolSignatures = append(toolSignatures, "mcp_discovery_regex")
}
if cfg.Tools.MCP.Discovery.UseBM25 {
toolSignatures = append(toolSignatures, "mcp_discovery_bm25")
}
if len(toolSignatures) > 0 {
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
}
return strings.Join(parts, ";")
}
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
if gatewayStatus != "running" { if gatewayStatus != "running" {
return false return false
} }
if strings.TrimSpace(configDefaultModel) == "" || strings.TrimSpace(bootDefaultModel) == "" { if bootSignature == "" || currentSignature == "" {
return false return false
} }
return configDefaultModel != bootDefaultModel return bootSignature != currentSignature
} }
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
@ -228,10 +308,11 @@ func attachToGatewayProcessLocked(pid int, cfg *config.Config) error {
gateway.owned = false // We didn't start this process gateway.owned = false // We didn't start this process
setGatewayRuntimeStatusLocked("running") setGatewayRuntimeStatusLocked("running")
// Update bootDefaultModel from config // Update bootDefaultModel and bootConfigSignature from config
if cfg != nil { if cfg != nil {
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
gateway.bootDefaultModel = defaultModelName gateway.bootDefaultModel = defaultModelName
gateway.bootConfigSignature = computeConfigSignature(cfg)
} }
logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid)) logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid))
@ -419,6 +500,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.cmd = cmd gateway.cmd = cmd
gateway.owned = true // We started this process gateway.owned = true // We started this process
gateway.bootDefaultModel = defaultModelName gateway.bootDefaultModel = defaultModelName
gateway.bootConfigSignature = computeConfigSignature(cfg)
setGatewayRuntimeStatusLocked(initialStatus) setGatewayRuntimeStatusLocked(initialStatus)
pid = cmd.Process.Pid pid = cmd.Process.Pid
logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)) logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath))
@ -439,6 +521,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
if gateway.cmd == cmd { if gateway.cmd == cmd {
gateway.cmd = nil gateway.cmd = nil
gateway.bootDefaultModel = "" gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
if gateway.runtimeStatus != "restarting" { if gateway.runtimeStatus != "restarting" {
setGatewayRuntimeStatusLocked("stopped") setGatewayRuntimeStatusLocked("stopped")
} }
@ -713,7 +796,7 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
func (h *Handler) gatewayStatusData() map[string]any { func (h *Handler) gatewayStatusData() map[string]any {
data := map[string]any{} data := map[string]any{}
configDefaultModel := "" var configDefaultModel string
cfg, cfgErr := config.LoadConfig(h.configPath) cfg, cfgErr := config.LoadConfig(h.configPath)
if cfgErr == nil && cfg != nil { if cfgErr == nil && cfg != nil {
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
@ -784,11 +867,14 @@ func (h *Handler) gatewayStatusData() map[string]any {
} }
} }
bootDefaultModel, _ := data["boot_default_model"].(string)
gatewayStatus, _ := data["gateway_status"].(string) gatewayStatus, _ := data["gateway_status"].(string)
data["gateway_restart_required"] = gatewayRestartRequired( currentConfigSignature := computeConfigSignature(cfg)
configDefaultModel, gateway.mu.Lock()
bootDefaultModel, bootConfigSignature := gateway.bootConfigSignature
gateway.mu.Unlock()
data["gateway_restart_required"] = gatewayRestartRequiredBySignature(
bootConfigSignature,
currentConfigSignature,
gatewayStatus, gatewayStatus,
) )

View file

@ -77,6 +77,7 @@ func resetGatewayTestState(t *testing.T) {
gateway.mu.Lock() gateway.mu.Lock()
gateway.cmd = nil gateway.cmd = nil
gateway.bootDefaultModel = "" gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped") setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock() gateway.mu.Unlock()
}) })
@ -502,9 +503,11 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
t.Fatalf("FindProcess() error = %v", err) t.Fatalf("FindProcess() error = %v", err)
} }
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock() gateway.mu.Lock()
gateway.cmd = &exec.Cmd{Process: process} gateway.cmd = &exec.Cmd{Process: process}
gateway.bootDefaultModel = cfg.ModelList[0].ModelName gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running") setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock() gateway.mu.Unlock()
@ -548,6 +551,188 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
} }
} }
func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].SetAPIKey("test-key")
cfg.Tools.WriteFile.Enabled = true
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
gateway.cmd = &exec.Cmd{Process: process}
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
updatedCfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
updatedCfg.Tools.WriteFile.Enabled = false
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
if got := body["gateway_restart_required"]; got != true {
t.Fatalf("gateway_restart_required = %#v, want true", got)
}
}
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].SetAPIKey("test-key")
cfg.Agents.Defaults.MaxTokens = 1000
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
gateway.cmd = &exec.Cmd{Process: process}
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
updatedCfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
updatedCfg.Agents.Defaults.MaxTokens = 2000
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
if got := body["gateway_restart_required"]; got != false {
t.Fatalf("gateway_restart_required = %#v, want false", got)
}
}
func TestGatewayStatusNoRestartRequiredWhenNotRunning(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].SetAPIKey("test-key")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
gateway.mu.Lock()
gateway.cmd = nil
gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped")
gateway.mu.Unlock()
updatedCfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
updatedCfg.Agents.Defaults.ModelName = "different-model"
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return nil, errors.New("no gateway running")
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "stopped" {
t.Fatalf("gateway_status = %#v, want %q", got, "stopped")
}
if got := body["gateway_restart_required"]; got != false {
t.Fatalf("gateway_restart_required = %#v, want false", got)
}
}
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) { func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
resetGatewayTestState(t) resetGatewayTestState(t)

View file

@ -32,6 +32,7 @@ import {
} from "@/components/config/form-model" } from "@/components/config/form-model"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { refreshGatewayState } from "@/store/gateway"
export function ConfigPage() { export function ConfigPage() {
const { t } = useTranslation() const { t } = useTranslation()
@ -281,6 +282,7 @@ export function ConfigPage() {
} }
toast.success(t("pages.config.save_success")) toast.success(t("pages.config.save_success"))
void refreshGatewayState({ force: true })
} catch (err) { } catch (err) {
toast.error( toast.error(
err instanceof Error ? err.message : t("pages.config.save_error"), err instanceof Error ? err.message : t("pages.config.save_error"),

View file

@ -19,6 +19,7 @@ import {
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { refreshGatewayState } from "@/store/gateway"
export function RawConfigPage() { export function RawConfigPage() {
const { t } = useTranslation() const { t } = useTranslation()
@ -56,6 +57,7 @@ export function RawConfigPage() {
} catch { } catch {
queryClient.invalidateQueries({ queryKey: ["config"] }) queryClient.invalidateQueries({ queryKey: ["config"] })
} }
void refreshGatewayState({ force: true })
}, },
onError: () => { onError: () => {
toast.error(t("pages.config.save_error")) toast.error(t("pages.config.save_error"))

View file

@ -14,6 +14,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
export function ToolsPage() { export function ToolsPage() {
const { t } = useTranslation() const { t } = useTranslation()
@ -33,6 +34,7 @@ export function ToolsPage() {
: t("pages.agent.tools.disable_success"), : t("pages.agent.tools.disable_success"),
) )
void queryClient.invalidateQueries({ queryKey: ["tools"] }) void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
}, },
onError: (err) => { onError: (err) => {
toast.error( toast.error(

View file

@ -66,7 +66,7 @@
"restarting": "Restarting Gateway...", "restarting": "Restarting Gateway...",
"stopping": "Stopping Gateway..." "stopping": "Stopping Gateway..."
}, },
"restartRequired": "Model changes require a gateway restart to take effect." "restartRequired": "Configuration changes require a gateway restart to take effect."
} }
}, },
"common": { "common": {

View file

@ -66,7 +66,7 @@
"restarting": "服务重启中...", "restarting": "服务重启中...",
"stopping": "服务停止中..." "stopping": "服务停止中..."
}, },
"restartRequired": "切换默认模型后需要重启服务才能生效。" "restartRequired": "配置变更后需要重启服务才能生效。"
} }
}, },
"common": { "common": {