From 4419fad7540e632c78300f6d2efd296f2b79d64b Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sun, 8 Mar 2026 18:44:04 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20cerrar=20issue=200013=20=E2=80=94=20ho?= =?UTF-8?q?t-reload=20implementado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mueve 0013-hot-reload.md a completed/ y actualiza el índice de issues. Co-Authored-By: Claude Sonnet 4.6 --- dev/issues/README.md | 2 +- dev/issues/completed/0013-hot-reload.md | 265 ++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 dev/issues/completed/0013-hot-reload.md diff --git a/dev/issues/README.md b/dev/issues/README.md index 0b13b31..1684d6f 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -17,7 +17,7 @@ afectados y notas de implementacion. | 10 | Access control | [0010-access-control.md](completed/0010-access-control.md) | completado | | 11 | Markdown rendering | [0011-markdown-rendering.md](completed/0011-markdown-rendering.md) | completado | | 12 | Threads | [0012-threads.md](completed/0012-threads.md) | completado | -| 13 | Hot reload | [0013-hot-reload.md](0013-hot-reload.md) | pendiente | +| 13 | Hot reload | [0013-hot-reload.md](completed/0013-hot-reload.md) | completado | | 14 | Template agent standardize | [0014-template-agent-standardize.md](0014-template-agent-standardize.md) | pendiente | | 15 | Multi-platform Telegram | [0015-multi-platform-telegram.md](0015-multi-platform-telegram.md) | pendiente | | 16 | Skills system | [0016-skills-system.md](0016-skills-system.md) | pendiente | diff --git a/dev/issues/completed/0013-hot-reload.md b/dev/issues/completed/0013-hot-reload.md new file mode 100644 index 0000000..04b0636 --- /dev/null +++ b/dev/issues/completed/0013-hot-reload.md @@ -0,0 +1,265 @@ +# Task 013 — Hot-Reload de Agentes Individuales + +## Objetivo + +Permitir reiniciar (recrear) un agente individual dentro del launcher sin detener +los demas agentes. El bus y el orquestador permanecen intactos porque todo sigue +en el mismo proceso. + +## Contexto + +Actualmente el launcher ejecuta todos los agentes como goroutines dentro de un +unico proceso. No hay forma de reiniciar un solo agente — hay que matar y +re-arrancar el launcher entero, lo que desconecta a todos los bots de Matrix +y rompe conversaciones en curso. + +### Por que no un proceso por agente + +El sistema de orquestacion multi-bot depende de: + +- **Bus in-process** (`shell/bus/bus.go`): Go channels, solo funciona dentro del mismo proceso. +- **Orquestador** (`shell/orchestration/`): usa el bus para `dispatchAndWait()` (request-response). +- **Deduplicacion** (`seen map`): estado compartido en memoria para evitar que multiples bots + en el mismo room procesen el mismo mensaje. +- **Interceptor**: callback sincrono que el listener de cada bot llama al orquestador. + +Separar en procesos romperia todo lo anterior. El hot-reload mantiene el proceso unico +pero recrea el agente internamente. + +## Mecanismo propuesto + +### Signal: SIGHUP + archivo de control + +1. El launcher escucha `SIGHUP` ademas de SIGINT/SIGTERM. +2. Al recibir SIGHUP, lee un archivo `run/reload.txt` que contiene el ID del agente a recargar. +3. Si el archivo no existe o esta vacio, recarga TODOS los agentes. +4. Alternativa: un comando via bus (`bus.KindReload`) enviado desde el TUI/agentctl. + +### Flujo de hot-reload + +``` +SIGHUP recibido (o comando reload via bus/TUI) + | + v +Launcher lee run/reload.txt -> agentID (o "*" para todos) + | + v +Para cada agente a recargar: + 1. Cancelar su context (ctx.cancel) -> Agent.Run() termina gracefully + 2. Esperar a que la goroutine termine (via WaitGroup o done channel) + 3. Desuscribir del bus (bus.Unsubscribe(agentID)) + 4. Re-leer config.yaml del agente + 5. Re-crear Agent con agents.New(cfg, rules, logger) + 6. Re-suscribir al bus (agent.SetBus) + 7. Re-conectar interceptor y membership notify si orquestador activo + 8. Re-registrar participante en orquestador + 9. Lanzar nueva goroutine con agent.Run(newCtx) + | + v +Log: "agent reloaded successfully" +``` + +## Plan de implementacion + +### 1. Hacer Agent cancelable individualmente + +**Archivo**: `agents/runtime.go` + +- Actualmente `Agent.Run(ctx)` recibe el context del launcher (compartido). +- Cambiar para que cada agente tenga su propio `context.WithCancel(parentCtx)`. +- Exponer un metodo `Agent.Stop()` que cancela el context hijo. +- Exponer un canal o metodo `Agent.Done() <-chan struct{}` para saber cuando termino. + +```go +type Agent struct { + // ... campos existentes ... + cancel context.CancelFunc + done chan struct{} +} + +func (a *Agent) Run(ctx context.Context) error { + ctx, a.cancel = context.WithCancel(ctx) + defer close(a.done) + // ... resto del Run existente ... +} + +func (a *Agent) Stop() { + if a.cancel != nil { + a.cancel() + } +} + +func (a *Agent) Done() <-chan struct{} { + return a.done +} +``` + +### 2. Anadir Unsubscribe al bus + +**Archivo**: `shell/bus/bus.go` + +- Nuevo metodo `Unsubscribe(id AgentID)` que elimina el canal del mapa y lo cierra. +- `listenBus()` en runtime.go debe manejar canal cerrado sin panic. + +```go +func (b *Bus) Unsubscribe(id AgentID) { + b.mu.Lock() + defer b.mu.Unlock() + if ch, ok := b.channels[id]; ok { + close(ch) + delete(b.channels, id) + } +} +``` + +### 3. Tracker de agentes en el launcher + +**Archivo**: `cmd/launcher/main.go` + +- Reemplazar el `sync.WaitGroup` actual por un registry de agentes vivos: + +```go +type runningAgent struct { + agent *agents.Agent + cancel context.CancelFunc + done chan struct{} + cfg *config.Config +} + +type agentRegistry struct { + mu sync.Mutex + agents map[string]*runningAgent +} +``` + +- Metodos: `register(id, agent)`, `stop(id)`, `reload(id, parentCtx)`, `stopAll()`. +- `reload(id)` ejecuta el flujo descrito arriba: stop -> wait -> recreate -> start. + +### 4. Handler de SIGHUP + +**Archivo**: `cmd/launcher/main.go` + +- Escuchar SIGHUP en un canal separado (no en el mismo NotifyContext de SIGINT/SIGTERM). +- Al recibir SIGHUP: + - Leer `run/reload.txt` (si existe) + - Llamar `registry.reload(id, ctx)` o `registry.reloadAll(ctx)` si es "*" + +```go +sighup := make(chan os.Signal, 1) +signal.Notify(sighup, syscall.SIGHUP) + +go func() { + for range sighup { + id := readReloadTarget("run/reload.txt") + if id == "" || id == "*" { + registry.reloadAll(ctx) + } else { + registry.reload(id, ctx) + } + } +}() +``` + +### 5. Integracion con el orquestador + +**Archivo**: `cmd/launcher/main.go` (dentro de `reload()`) + +Al recrear un agente que participa en orquestacion: + +1. El orquestador no necesita "desregistrar" al participante — basta con re-registrar + con la misma info (sobreescribe). +2. Re-llamar `SetInterceptor` y `SetMembershipNotify` en el nuevo Agent. +3. El bus.Subscribe del nuevo agente devuelve un canal nuevo — el orquestador usa + `bus.Send(agentID)` que resuelve el nuevo canal automaticamente. + +**Caso critico**: si el agente esta en medio de un `dispatchAndWait()` cuando se cancela: +- El context se cancela -> SendAndWait retorna error +- El orquestador recibe timeout/error para esa iteracion +- La respuesta parcial se pierde pero no hay corrupcion +- El orquestador puede reintentar o pasar al siguiente bot + +### 6. Integracion con el TUI + +**Archivos**: `pkg/tui/update.go`, `shell/tui/adapter.go`, `shell/process/manager.go` + +El boton "Restart" del TUI (task actual) debe cambiar de "kill+start launcher" a: + +1. Escribir el agentID en `run/reload.txt` +2. Enviar SIGHUP al proceso del launcher (`kill -HUP `) +3. Esperar un momento y refrescar estado + +```go +func (a *Adapter) restartAgent(id string) tea.Cmd { + return func() tea.Msg { + // Escribir target en reload file + os.WriteFile("run/reload.txt", []byte(id), 0644) + // Enviar SIGHUP al launcher + pid := a.mgr.UnifiedPID() + if pid > 0 { + syscall.Kill(pid, syscall.SIGHUP) + } + time.Sleep(1 * time.Second) + return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: nil} + } +} +``` + +### 7. Integracion con agentctl CLI + +**Archivo**: `cmd/agentctl/main.go` + +- Nuevo subcomando: `agentctl reload ` +- Escribe `run/reload.txt` + envia SIGHUP +- Mismo mecanismo que el TUI + +### 8. Graceful shutdown del agente + +**Archivo**: `agents/runtime.go` + +Al cancelar el context individual de un agente: + +1. El sync loop de Matrix debe detenerse limpiamente (mautrix tiene `StopSync()`) +2. Las llamadas LLM en curso deben cancelarse via context +3. La tool execution en curso debe respetar context cancellation +4. Memory/knowledge stores deben flush antes de cerrar +5. El canal del bus se cierra — `listenBus` sale del loop + +Verificar que `runtime.go:Run()` ya maneja todo esto con el context actual. +Si no, anadir cleanup explicicto. + +### 9. Tests + +- **Unit test**: `bus.Unsubscribe` no causa panic, mensajes posteriores al unsubscribe + no se pierden (retornan error). +- **Unit test**: `agentRegistry.reload()` — stop + recreate funciona. +- **Integration test**: enviar SIGHUP y verificar que solo el agente target se reinicia. +- **Orchestrator test**: agente en medio de task, se cancela, orquestador maneja el error. + +## Orden de implementacion sugerido + +1. `Agent.Stop()` + `Agent.Done()` (runtime.go) +2. `Bus.Unsubscribe()` (bus.go) +3. `agentRegistry` en launcher (main.go) +4. Handler SIGHUP (main.go) +5. Graceful shutdown verification (runtime.go) +6. Actualizar TUI adapter (adapter.go) +7. Actualizar agentctl (agentctl/main.go) +8. Tests + +## Riesgos y mitigaciones + +| Riesgo | Mitigacion | +|--------|------------| +| Race condition al cerrar canal del bus | Mutex en Unsubscribe, recover en Send | +| Crypto store de mautrix queda locked | Cerrar store explicitamente en cleanup | +| Orquestador en medio de dispatch | Context cancellation + timeout ya existente | +| Config invalido al recargar | Validar config antes de destruir agente viejo | +| Matrix sync no para limpio | Llamar StopSync() explicitamente antes de cancel | + +## Notas + +- SIGHUP es la convencion Unix para "recargar configuracion" (nginx, haproxy, etc.) +- El archivo `run/reload.txt` es efimero — se puede borrar despues de leer +- Si el launcher no esta corriendo, el TUI debe caer al comportamiento actual (start launcher) +- El orquestador NO se recarga — solo los agentes. Para recargar el orquestador + hay que reiniciar el launcher entero.