4419fad754
Mueve 0013-hot-reload.md a completed/ y actualiza el índice de issues. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
8.4 KiB
Markdown
266 lines
8.4 KiB
Markdown
# 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.
|
|
|
|
```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 <pid>`)
|
|
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 <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.
|