# 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.