Mueve 0013-hot-reload.md a completed/ y actualiza el índice de issues. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 paradispatchAndWait()(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
- El launcher escucha
SIGHUPademas de SIGINT/SIGTERM. - Al recibir SIGHUP, lee un archivo
run/reload.txtque contiene el ID del agente a recargar. - Si el archivo no existe o esta vacio, recarga TODOS los agentes.
- 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.WaitGroupactual 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)oregistry.reloadAll(ctx)si es "*"
- Leer
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:
- El orquestador no necesita "desregistrar" al participante — basta con re-registrar con la misma info (sobreescribe).
- Re-llamar
SetInterceptorySetMembershipNotifyen el nuevo Agent. - 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:
- Escribir el agentID en
run/reload.txt - Enviar SIGHUP al proceso del launcher (
kill -HUP <pid>) - 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:
- El sync loop de Matrix debe detenerse limpiamente (mautrix tiene
StopSync()) - Las llamadas LLM en curso deben cancelarse via context
- La tool execution en curso debe respetar context cancellation
- Memory/knowledge stores deben flush antes de cerrar
- El canal del bus se cierra —
listenBussale 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.Unsubscribeno 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
Agent.Stop()+Agent.Done()(runtime.go)Bus.Unsubscribe()(bus.go)agentRegistryen launcher (main.go)- Handler SIGHUP (main.go)
- Graceful shutdown verification (runtime.go)
- Actualizar TUI adapter (adapter.go)
- Actualizar agentctl (agentctl/main.go)
- 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.txtes 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.