Files
agents_and_robots/dev/issues/completed/0013-hot-reload.md
T
egutierrez 4419fad754 chore: cerrar issue 0013 — hot-reload implementado
Mueve 0013-hot-reload.md a completed/ y actualiza el índice de issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:44:04 +00:00

8.4 KiB

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 <id> 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.
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.
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:
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 "*"
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 <pid>)
  3. Esperar un momento y refrescar estado
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 <agent-id>
  • 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.