feat: controles de hot-reload por agente en el dashboard TUI

Añade opciones de Reload (hot-reload) separadas de Restart (reinicio
completo) en el dashboard, usando el mecanismo SIGHUP implementado en
el issue 0013.

Cambios en pkg/tui/ (capa pura):
- IntentReloadAgent: hot-reload de un agente individual via SIGHUP
- IntentReloadAll: hot-reload de todos los agentes via SIGHUP
- AgentActionOptions: añade "Reload" antes de "Restart" con descripciones
  clarificadas ("sin interrumpir los demás" vs "launcher completo")
- ServerMenuOptions (running): añade "Reload All" como primera opción
- executeAction: maneja "Reload" → IntentReloadAgent
- executeServerAction: maneja "Reload All" → IntentReloadAll
- Mensajes de estado diferenciados: "Reload OK — X recargado sin
  interrupciones" vs "Restart OK — launcher reiniciado"

Cambios en shell/tui/ (capa impura):
- reloadAgent(id): escribe run/reload.txt + SIGHUP; error si launcher
  no está corriendo (no hay fallback a full restart)
- reloadAll(): elimina reload.txt + SIGHUP; error si no está corriendo
- restartAgent(id): restaurado a su comportamiento original de
  stop+start completo del launcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 18:49:00 +00:00
parent 6829de37c3
commit f459d4e255
3 changed files with 66 additions and 18 deletions
+5 -2
View File
@@ -96,6 +96,7 @@ func TestMenuOptions() []MenuOption {
func ServerMenuOptions(running bool) []MenuOption { func ServerMenuOptions(running bool) []MenuOption {
if running { if running {
return []MenuOption{ return []MenuOption{
{Label: "Reload All", Desc: "Hot-reload de todos los agentes (SIGHUP)"},
{Label: "Stop", Desc: "Detener el launcher"}, {Label: "Stop", Desc: "Detener el launcher"},
{Label: "Restart", Desc: "Reiniciar el launcher"}, {Label: "Restart", Desc: "Reiniciar el launcher"},
{Label: "Kill", Desc: "SIGKILL forzado"}, {Label: "Kill", Desc: "SIGKILL forzado"},
@@ -115,13 +116,15 @@ func ServerMenuOptions(running bool) []MenuOption {
func AgentActionOptions(enabled bool) []MenuOption { func AgentActionOptions(enabled bool) []MenuOption {
if enabled { if enabled {
return []MenuOption{ return []MenuOption{
{Label: "Restart", Desc: "Reiniciar launcher para aplicar cambios"}, {Label: "Reload", Desc: "Hot-reload este agente (SIGHUP, sin interrumpir los demás)"},
{Label: "Restart", Desc: "Reiniciar el launcher completo (todos los agentes)"},
{Label: "Disable", Desc: "Desactivar agente (requiere restart)"}, {Label: "Disable", Desc: "Desactivar agente (requiere restart)"},
{Label: "Logs", Desc: "Ver log del launcher"}, {Label: "Logs", Desc: "Ver log del launcher"},
} }
} }
return []MenuOption{ return []MenuOption{
{Label: "Restart", Desc: "Reiniciar launcher para aplicar cambios"}, {Label: "Reload", Desc: "Hot-reload este agente (SIGHUP, sin interrumpir los demás)"},
{Label: "Restart", Desc: "Reiniciar el launcher completo (todos los agentes)"},
{Label: "Enable", Desc: "Activar agente (requiere restart)"}, {Label: "Enable", Desc: "Activar agente (requiere restart)"},
{Label: "Logs", Desc: "Ver log del launcher"}, {Label: "Logs", Desc: "Ver log del launcher"},
} }
+14 -2
View File
@@ -14,7 +14,9 @@ const (
// Agent-level // Agent-level
IntentEnableAgent IntentKind = "enable_agent" IntentEnableAgent IntentKind = "enable_agent"
IntentDisableAgent IntentKind = "disable_agent" IntentDisableAgent IntentKind = "disable_agent"
IntentRestartAgent IntentKind = "restart_agent" IntentReloadAgent IntentKind = "reload_agent" // hot-reload via SIGHUP (solo este agente)
IntentReloadAll IntentKind = "reload_all" // hot-reload via SIGHUP (todos los agentes)
IntentRestartAgent IntentKind = "restart_agent" // restart completo del launcher
// Unified launcher operations // Unified launcher operations
IntentStartLauncher IntentKind = "start_launcher" IntentStartLauncher IntentKind = "start_launcher"
@@ -74,8 +76,10 @@ func Update(model Model, msg interface{}) (Model, []Intent) {
case MsgActionDone: case MsgActionDone:
if m.Err != nil { if m.Err != nil {
model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err) model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err)
} else if m.Action == "Reload" {
model.StatusMsg = fmt.Sprintf("Reload OK — %s recargado sin interrupciones", m.AgentID)
} else if m.Action == "Restart" { } else if m.Action == "Restart" {
model.StatusMsg = fmt.Sprintf("Restart OK — all agents reloaded") model.StatusMsg = "Restart OK — launcher reiniciado"
} else { } else {
model.StatusMsg = fmt.Sprintf("%s %s OK — restart launcher to apply", m.Action, m.AgentID) model.StatusMsg = fmt.Sprintf("%s %s OK — restart launcher to apply", m.Action, m.AgentID)
} }
@@ -84,6 +88,8 @@ func Update(model Model, msg interface{}) (Model, []Intent) {
case MsgServerActionDone: case MsgServerActionDone:
if m.Err != nil { if m.Err != nil {
model.StatusMsg = fmt.Sprintf("Error: %s: %v", m.Action, m.Err) model.StatusMsg = fmt.Sprintf("Error: %s: %v", m.Action, m.Err)
} else if m.Action == "Reload All" {
model.StatusMsg = "Reload All OK — SIGHUP enviado al launcher"
} else { } else {
model.StatusMsg = fmt.Sprintf("%s OK", m.Action) model.StatusMsg = fmt.Sprintf("%s OK", m.Action)
} }
@@ -245,6 +251,9 @@ func executeAction(model Model, action string) (Model, []Intent) {
case "Disable": case "Disable":
model.StatusMsg = "Disabling " + id + "..." model.StatusMsg = "Disabling " + id + "..."
return model, []Intent{{Kind: IntentDisableAgent, AgentID: id}} return model, []Intent{{Kind: IntentDisableAgent, AgentID: id}}
case "Reload":
model.StatusMsg = "Hot-reloading " + id + "..."
return model, []Intent{{Kind: IntentReloadAgent, AgentID: id}}
case "Restart": case "Restart":
model.StatusMsg = "Restarting launcher (all agents)..." model.StatusMsg = "Restarting launcher (all agents)..."
return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}} return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}}
@@ -302,6 +311,9 @@ func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) {
func executeServerAction(model Model, action string) (Model, []Intent) { func executeServerAction(model Model, action string) (Model, []Intent) {
switch action { switch action {
case "Reload All":
model.StatusMsg = "Hot-reloading all agents..."
return model, []Intent{{Kind: IntentReloadAll}}
case "Start": case "Start":
model.StatusMsg = "Starting launcher..." model.StatusMsg = "Starting launcher..."
return model, []Intent{{Kind: IntentStartLauncher}} return model, []Intent{{Kind: IntentStartLauncher}}
+47 -14
View File
@@ -39,6 +39,12 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
case puretui.IntentDisableAgent: case puretui.IntentDisableAgent:
return a.disableAgent(intent.AgentID) return a.disableAgent(intent.AgentID)
case puretui.IntentReloadAgent:
return a.reloadAgent(intent.AgentID)
case puretui.IntentReloadAll:
return a.reloadAll()
case puretui.IntentRestartAgent: case puretui.IntentRestartAgent:
return a.restartAgent(intent.AgentID) return a.restartAgent(intent.AgentID)
@@ -140,30 +146,57 @@ func (a *Adapter) disableAgent(id string) tea.Cmd {
} }
} }
func (a *Adapter) restartAgent(id string) tea.Cmd { // reloadAgent hot-reloads a single agent via SIGHUP without stopping the launcher.
func (a *Adapter) reloadAgent(id string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
pid := a.mgr.UnifiedPID() pid := a.mgr.UnifiedPID()
if pid <= 0 { if pid <= 0 {
// Launcher not running — fall back to full restart. return puretui.MsgActionDone{AgentID: id, Action: "Reload",
_ = a.mgr.StopUnified() Err: fmt.Errorf("el launcher no está corriendo")}
time.Sleep(500 * time.Millisecond)
err := a.mgr.StartUnified()
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
} }
// Launcher is running — write target and send SIGHUP for hot-reload.
if id != "" { if id != "" {
_ = os.WriteFile("run/reload.txt", []byte(id), 0o644) if err := os.WriteFile("run/reload.txt", []byte(id), 0o644); err != nil {
return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: err}
}
} }
err := syscall.Kill(pid, syscall.SIGHUP) err := syscall.Kill(pid, syscall.SIGHUP)
if err != nil { if err != nil {
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: err}
} }
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: nil} return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: nil}
}
}
// reloadAll hot-reloads all agents via SIGHUP (no reload.txt → reload all).
func (a *Adapter) reloadAll() tea.Cmd {
return func() tea.Msg {
pid := a.mgr.UnifiedPID()
if pid <= 0 {
return puretui.MsgServerActionDone{Action: "Reload All",
Err: fmt.Errorf("el launcher no está corriendo")}
}
// Remove stale reload.txt so the launcher reloads all agents.
_ = os.Remove("run/reload.txt")
err := syscall.Kill(pid, syscall.SIGHUP)
if err != nil {
return puretui.MsgServerActionDone{Action: "Reload All", Err: err}
}
time.Sleep(1 * time.Second)
return puretui.MsgServerActionDone{Action: "Reload All", Err: nil}
}
}
// restartAgent stops and restarts the whole launcher (full restart, all agents).
func (a *Adapter) restartAgent(id string) tea.Cmd {
return func() tea.Msg {
_ = a.mgr.StopUnified()
time.Sleep(500 * time.Millisecond)
err := a.mgr.StartUnified()
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
} }
} }