U-12 · pruebas · agosto de 2026

La granja de agentes: un orquestador que reparte tareas entre agentes de código

Tengo dos proyectos activos — espacio-latente (este sitio) y ai-trading-lab — y una lista de tareas más larga que las horas que les puedo dedicar. La idea de la granja de agentes es simple de enunciar y no tan simple de montar bien: un orquestador que coge una tarea, la descompone, la reparte entre agentes que corren en contenedores aislados, verifica el resultado con un segundo agente, y me avisa por Telegram. Sin que yo tenga que estar delante.

Esta bitácora cuenta cómo está montado hoy, qué decisiones se han tomado y por qué, y qué queda pendiente.

TELEGRAM /new /tasks /status ORCHESTRATOR API POST /api/tasks · /api/boards BOARD (SQLite) 1 board por proyecto: espacio-latente · ai-trading-lab triage → todo → ready → running → done / blocked / gave_up DISPATCHER tick cada 30s: promociona, reclama, lanza contenedores MAKER contenedor Docker · backend/frontend/devops/sre CHECKER contenedor Docker · qa/security, verifica al maker RESULT_JSON: MEMANTO memoria por board, on-prem Ollama + Moorcheh, en el sobremesa
El camino de una tarea: Telegram → API → board SQLite → dispatcher → contenedor (maker, y opcionalmente checker) → resultado estructurado de vuelta al board como RESULT_JSON (status/summary/learnings/files_changed). Memanto consulta y escribe memoria por proyecto en cada paso.

La base: un fork, no un proyecto propio

La granja está construida sobre agent-loops, de Daniel Fernández — un repo suyo que cloné hace tiempo. Antes de construir nada encima me hice la pregunta obvia: ¿lo uso solo porque ya lo tengo clonado, o de verdad aguanta el peso?

La respuesta, tras auditar el código, fue que sí aguanta: ~5.300 líneas repartidas en módulos con una responsabilidad clara cada uno (board.js, dispatcher.js, docker-runner.js, git-manager.js, el conector de Telegram), con un test por módulo. No es deuda técnica escondida — es prácticamente la misma arquitectura a la que yo habría llegado: orquestador + cola + runner de Docker + bot. El riesgo real no era la calidad del código, era heredar decisiones de diseño ajenas sin darme cuenta. Ese riesgo se gestiona sobre la marcha, no reescribiendo 5.300 líneas para llegar al mismo sitio.

Lo que ya estaba construido

Un orquestador Node.js con SQLite como “board” (tablero kanban), que:

Lo que le faltaba para servir a dos proyectos reales a la vez, y lo que he cerrado hoy, son tres piezas.

Pieza A — multi-board de verdad

La tabla boards existía en el esquema pero no había forma de crear uno ni de decirle a una tarea a qué board pertenece salvo por su id numérico. Ahora:

Pieza B — un contrato en vez de adivinar logs

Esta era la pieza que más me preocupaba de la auditoría original. El orquestador se enteraba de si un agente había terminado bien leyendo el log de texto plano del contenedor, con tres heurísticas distintas buscando frases como SESSION_COMPLETE: o Session complete!. Frágil, y sobre todo: esas frases no las pedía ningún prompt actual — la plantilla decía «escribe un iteration-summary» y el parser buscaba un marcador que nadie emitía.

Ahora el contrato es explícito. Las seis plantillas de agente (agents/templates/*.template) exigen que el último mensaje del agente termine con:

RESULT_JSON:
{"status":"success","summary":"...","learnings":["..."],"files_changed":["..."]}

Y dispatcher.js ya no adivina: busca el marcador, valida el JSON, y si no aparece o no parsea no lo esconde — emite un evento result_protocol_missing visible en el board. Un runtime mal configurado se detecta en vez de disfrazarse de éxito silencioso. learnings es, además, el campo que va a alimentar memoria persistente en la siguiente pieza.

Pieza D — agrupación visual (cosmética, pero no gratis)

El board interno mantiene el enum de estados original (triage/todo/ready/running/blocked/done/archived/gave_up) porque todo el dispatcher ya razona sobre él. Pero para mirarlo de un vistazo, cuatro columnas dicen más que ocho estados: Ready · In Progress · Ready to Verify · Done/Discarded.

Lo interesante de “Ready to Verify” es que no es un estado nuevo — es una tarea ready cuyo assignee es un checker (qa/security) y cuyos hermanos maker ya han terminado. Esa condición ya existía en el dispatcher (hasRunningSiblings, la usa para decidir si lanza el checker o lo deja esperando); lo único que hice fue exponerla como vista, sin tocar el estado real.

Pieza C — memoria persistente, y por qué no vive donde yo pensaba

Antes de esta pieza, cada tarea empezaba de cero: nada de lo aprendido en una tarea llegaba a la siguiente. La idea era sencilla — antes de generar el spec de una tarea, consultar una memoria acotada al proyecto (mismo namespace que el board slug), y al terminar, escribir de vuelta usando el campo learnings del contrato de la Pieza B. Lo interesante no fue el código (poco: queryMemories/writeMemory, fail-open, dos puntos de enganche), sino dónde vive esa memoria.

Herramienta: memantoremember/recall/answer sobre un motor de búsqueda semántica (Moorcheh) que se puede correr en su nube gratuita o on-prem. Descarté la nube por el mismo motivo de siempre en este blog: los datos de mis proyectos saliendo de mi red por una comodidad que además no hacía falta. Miré también Mem0 — más abierto y maduro — pero su imagen oficial solo trae proveedores cloud (OpenAI/Anthropic/Gemini) para el LLM; montarlo 100% local habría exigido reconstruir su imagen Docker.

Dónde, y por qué no donde pensaba. Mi primer instinto fue el NAS (Synology DS220+, siempre encendido). Craso error: un Celeron J4025 de 2 núcleos no da para correr Ollama con un LLM local a una latencia razonable, tuviera los contenedores que tuviera parados. El sitio correcto estaba delante de mis narices: el sobremesa, que ya corre un modelo de 35B para otro proyecto (Hermes, vía llama.cpp) y tiene una RTX 3090 Ti y 64 GB de RAM de sobra. El único “pero” — no está encendido 24/7 — pesa menos de lo que parece: coincide con cuándo trabajo, y ya hay Wake-on-LAN integrado para otros flujos.

Aquí entra un principio que ya se aplicó en otro proyecto de esta casa: mantener el peso del modelo en local, sin depender de una API externa como cerebro del sistema. Ese criterio, más que el benchmark de turno, fue el que decidió la arquitectura.

Lo que costó montarlo (para cuando lo vuelva a instalar):

Con eso resuelto: dos agentes de memoria activos (espacio-latente, ai-trading-lab, uno por proyecto), probados de punta a punta con datos reales, y con persistencia — sobreviven a un reinicio del sobremesa sin que nadie tenga que iniciar sesión.

SOBREMESA (192.168.1.32) · systemd --user, persiste tras reinicio OLLAMA :11434 (0.0.0.0) nomic-embed-text qwen2.5 MOTOR MOORCHEH :8090 (Docker) restart: unless-stopped Apache-2.0 MEMANTO API :8000 (memanto.service) remember · recall · answer linger activo, sin login orquestador HTTP :8000
Tres procesos encadenados, cada uno con su propia persistencia (systemd de sistema, política de reinicio de Docker, systemd de usuario con linger) — ninguno depende de que yo tenga sesión iniciada.

Un auditor de deuda como parte del ciclo, no como extra

Después de cerrar las cuatro piezas, hice a mano un barrido de código muerto sobre agent-loops — exports sin caller, tests huérfanos. Encontré unas cuantas cosas. Luego probé ponytail, un plugin de Claude Code que fuerza una disciplina de “el código que nunca se escribe no hay que mantenerlo” y trae un comando de auditoría de todo el repo (/ponytail-audit). Lo corrí sobre el mismo repo que ya había mirado a mano, y encontró cosas que a mí se me habían escapado — incluida una duplicación que yo mismo acababa de introducir esa misma tarde (dos funciones casi idénticas para la misma condición, una en dispatcher.js y otra en status-renderer.js, en vez de una sola en board.js).

Cada hallazgo se verificó antes de tocar nada — que de verdad no tuviera caller, que los tests siguieran en verde después del cambio — y solo entonces se aplicó. Sin eso, “auditoría automática” es solo una lista de sugerencias sin criterio.

La conclusión práctica: esto no es una herramienta que se prueba una vez y se archiva, es un paso que tiene sentido repetir cada vez que el código crece — así que pasa a formar parte del ciclo de trabajo de este proyecto, no de una sesión suelta. Un timer de systemd de usuario (domingos 21:00, el mismo patrón que ya usaba para la revisión diaria de ai-trading-lab) lo dispara solo, repo por repo:

ponytail-audit repo completo verificar a mano grep de callers reales tests: baseline antes de tocar nada aplicar + tests ¿misma cobertura? ¿regresión? sí → revertir no → commit local (nunca push)
Primera ejecución real (2026-08-03): 6 repos auditados, 5 commits locales aplicados en 3 de ellos, 0 pushes. Un flag muerto de ~1200 líneas se detectó y se dejó anotado para revisión manual en vez de tocarlo sin supervisión — borrar un subsistema entero no es una decisión para un cron.

Lo que sigue sin decidir, a propósito

El runtime que corre dentro de cada contenedor (iteratr + opencode, hoy) podría cambiar a Claude Code CLI más adelante. Esa decisión se ha dejado fuera a propósito: todo lo construido en esta fase —el protocolo RESULT_JSON, el multi-board, la agrupación visual— es agnóstico a qué modelo o runtime hay dentro del contenedor. Si cambia el runtime, solo hace falta que el hook de fin de sesión emita el mismo marcador.

← volver al rack