Hive ACP v0.2.0: Notificaciones que no se pierden
por Hugo Hernández Valdez

De orquestación a confiabilidad
En la segunda parte construí un sistema de orquestación multi-agente donde Kiro y OpenCode trabajan juntos, con un JobManager que despacha tareas en paralelo y un knowledge graph que persiste hechos entre sesiones. Funcionaba, pero bajo carga real aparecieron problemas sutiles que minaban la confianza en el sistema.
El peor: le pides al bot que analice un repositorio con 3 subagentes en paralelo. Los 3 terminan exitosamente. Pero tú nunca recibes el resultado. El bot se queda en silencio. No hay error en los logs. No hay crash. Simplemente... nada.
Eso pasaba cuando el timing era exacto: un job terminaba justo mientras el orquestador procesaba otro mensaje. El resultado se encolaba en un limbo del que nunca salía. Este release se enfocó en resolver esa clase de problemas — los que no crashean pero te hacen desconfiar de tu propio sistema.
El costo de EventEmitter sin tipos
Node.js EventEmitter es una bomba de tiempo silenciosa. Todo compila, nada falla en runtime (solo se ignora), y el bug aparece como "a veces no funciona":
// Esto compila perfecto. Es un bug.
client.emit("chnuk", text); // typo → evento nunca llega
client.on("tool", (name: number) => {}); // tipo incorrecto → crash en runtime
jobManager.emit("events", evt); // plural → nadie escucha
Con 3 clases emitiendo eventos, listeners en 4 archivos diferentes, y refactors frecuentes, era cuestión de tiempo antes de que un typo causara un bug imposible de rastrear.
La solución: 40 líneas que cambian todo
export class TypedEmitter<E extends Record<string, (...args: any[]) => void>> {
private emitter = new EventEmitter();
on<K extends keyof E & string>(event: K, listener: E[K]): this {
this.emitter.on(event, listener as any);
return this;
}
emit<K extends keyof E & string>(event: K, ...args: Parameters<E[K]>): boolean {
return this.emitter.emit(event, ...args);
}
off<K extends keyof E & string>(event: K, listener: E[K]): this {
this.emitter.off(event, listener as any);
return this;
}
}
Ahora cada clase declara exactamente qué eventos emite y con qué tipos:
export type AcpEvents = {
chunk: (text: string) => void;
tool: (name: string, toolCallId: string) => void;
tool_update: (toolCallId: string, status: string) => void;
turn_end: (text: string) => void;
exit: (code: number | null) => void;
};
export class AcpClient extends TypedEmitter<AcpEvents> { ... }
El resultado: si escribo client.emit("chnuk", text), el build falla. No en runtime, no en producción a las 3am — falla en mi editor, con un subrayado rojo, antes de guardar el archivo.
❌ Error: Argument of type '"chnuk"' is not assignable to parameter of type '"chunk" | "tool" | "tool_update" | "turn_end" | "exit"'
Un detalle que me costó 20 minutos: TypeScript rechaza interface como constraint de Record<string, ...> porque las interfaces no tienen index signatures implícitas. La solución es usar type en vez de interface para los event maps.
El bug de las notificaciones perdidas
Este era el bug más frustrante del proyecto. El flujo:
1. Usuario envía mensaje → orquestador empieza a procesar (busy=true)
2. Job de subagentes termina → resultados se encolan
3. drainToAgent() ve que el client está busy → re-encola y retorna null
4. Adapter recibe null → envía fallback genérico "Job finished"
5. Resultados quedan en la cola... para siempre
El usuario veía "📋 Job finished — 3/3 tasks completed" pero nunca recibía el contenido real. Los resultados solo aparecían si el usuario enviaba otro mensaje después (porque el siguiente prompt los consumía de la cola).
Primer intento: backoff con timer
const BACKOFF = [2_000, 4_000, 8_000, 16_000]; // ~30s total
for (let attempt = 0; attempt <= BACKOFF.length; attempt++) {
if (!entry.busy) break; // ya libre, continuar
await new Promise((r) => setTimeout(r, BACKOFF[attempt]));
}
Funcionaba para prompts de 5-10 segundos. Pero si el usuario pedía "analiza todo el repositorio" y el orquestador tardaba 2 minutos, los 30 segundos se agotaban. Vuelta al mismo problema.
Solución correcta: no adivines, escucha
La respuesta era obvia una vez que dejé de pensar en timers: esperar a que el evento ocurra, no adivinar cuándo va a ocurrir.
AcpPool ahora emite idle cuando un client pasa de busy a libre:
setBusy(chatId: number, busy: boolean): void {
const entry = this.pool.get(chatId);
if (!entry) return;
const wasBusy = entry.busy;
entry.busy = busy;
if (wasBusy && !busy) this.emit("idle", chatId);
}
Y drainToAgent simplemente espera ese evento:
if (entry.busy) {
log.acp.debug({ chatId }, "client busy, waiting for idle event");
const idle = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => { cleanup(); resolve(false); }, 5 * 60_000);
const onIdle = (id: number) => {
if (id !== chatId) return;
cleanup();
resolve(true);
};
const cleanup = () => { clearTimeout(timeout); this.off("idle", onIdle); };
this.on("idle", onIdle);
});
if (!idle) {
this.inject(chatId, queued); // 5 min sin respuesta = algo murió
return null;
}
}
No importa si el prompt tarda 3 segundos o 3 minutos. El drain se ejecuta inmediatamente cuando el orquestador queda libre. Cero polling, cero desperdicio, cero notificaciones perdidas.
El timeout de 5 minutos es solo un safety net para el caso extremo de que el proceso muera sin emitir el evento.
De 590 líneas a módulos reutilizables
El TelegramAdapter era un archivo de 590 líneas donde convivían:
- Conversión Markdown→HTML con manejo de code blocks
- Splitting de mensajes respetando UTF-16 surrogate pairs
- Rate limiting con RetryAfter de Telegram
- Toda la lógica del bot
El problema no era solo la longitud — era que cuando llegue el adapter de Slack, iba a necesitar el throttle y el splitting pero no podía importarlos sin traer todo Telegram.
Extracción
src/utils/telegram-html.ts (78 líneas):
export function escapeHtml(text: string): string;
export function mdToHtml(text: string): string; // **bold** → <b>bold</b>
export function splitMessage(text: string, maxLen?: number): string[];
src/utils/throttle.ts (42 líneas):
export class OutboundThrottle {
async wait(): Promise<void>; // espera hasta poder enviar
tryNow(): boolean; // intenta sin bloquear
defer(ms: number): void; // backoff por RetryAfter
}
export function getRetryAfter(err: unknown): number | null;
El adapter quedó en ~460 LOC — solo lógica de Telegram. Las utilidades son importables desde cualquier futuro adapter.
El agente que no podía guardar fotos
Un usuario me reportó: "Le envío una imagen al bot y le digo que la guarde en el proyecto. Me dice que no puede acceder al archivo."
Tenía razón. El flujo era:
1. Usuario envía foto
2. Adapter descarga como base64, la pasa al agente en el prompt
3. Agente "ve" la imagen (puede describirla, analizarla)
4. Agente intenta guardarla → no tiene acceso al binario
El agente recibía la imagen como contenido visual del prompt, pero no como un archivo que pudiera manipular. Es la misma limitación que tienen los LLMs con imágenes: las "ven" pero no pueden extraer el binario de vuelta.
Nueva tool: telegram_download_attachment
{
name: "telegram_download_attachment",
description: "Download the last attachment the user sent to /tmp and return the path.",
}
Ahora cuando el adapter procesa una foto o documento, guarda el file_id en el contexto:
private setAttachment(chatId: number, fileId: string, fileName: string, mimeType: string): void {
const ctx = this.activeCtx.get(chatId);
if (ctx) ctx.attachment = { fileId, fileName, mimeType };
}
Cuando el agente necesita el archivo, llama la tool. La tool descarga vía Telegram API y retorna la ruta:
/tmp/telegram-1715180400000-photo.jpg
Desde ahí el agente puede copiarla al workspace, procesarla, o lo que necesite.
Errores que se tragaban en silencio
El promptLock serializa prompts — si envías dos mensajes rápido, el segundo espera a que el primero termine. Pero el error handler era:
this.promptLock = result.then(() => {}, () => {});
// ^^^^^^^^ error tragado
Si un prompt fallaba (timeout, proceso muerto, error de red), el lock se liberaba correctamente pero nadie se enteraba. En los logs aparecía como si nada hubiera pasado. El siguiente prompt funcionaba normal, y el error anterior se perdía en el vacío.
Ahora:
this.promptLock = result.then(() => {}, (err) => {
log.acp.warn({ err: err?.message }, "prompt failed (lock released)");
});
Una línea. La diferencia entre "el bot a veces no responde y no sé por qué" y "ah, el prompt falló por timeout a las 14:32".
Limpieza con TypeScript estricto
Corrí tsc --noUnusedLocals --noUnusedParameters y encontré:
src/acp/pool.ts(56,23): error TS6138: Property 'registry' is declared but its value is never read.
src/adapters/chat/telegram/adapter.ts(252,9): error TS6133: 'acpInstance' is declared but its value is never read.
Dos variables fantasma que sobrevivieron refactors anteriores. Eliminadas. El proyecto ahora pasa el check estricto limpio — y debería ser parte del CI.
Impacto medible
| Métrica | v0.1.0 | v0.2.0 |
|---|---|---|
| Adapter LOC | ~590 | ~460 (-22%) |
| Bugs de tipado posibles en eventos | ∞ | 0 (compile-time) |
| Notificaciones perdidas por busy | Frecuente | Eliminado |
| Tiempo máximo de espera para drain | 30s (luego se pierde) | Hasta que termine (event-driven) |
| Módulos reutilizables para adapters | 0 | 2 |
| Dead code | 2 variables | 0 |
| Errores silenciosos en promptLock | Todos | 0 (logueados) |
Lecciones
-
Event-driven > polling: Si estás haciendo
setTimeouten un loop esperando que algo cambie, probablemente deberías emitir un evento cuando cambie. El código es más simple, más eficiente, y no tiene edge cases de timing. -
40 líneas de infraestructura ahorran horas de debugging:
TypedEmitteres trivial de implementar. El ROI es enorme — cada typo en un evento que TypeScript atrapa es un bug que no vas a debuggear en producción. -
Extrae utilidades antes de necesitarlas en dos lugares: Si esperas a tener duplicación para extraer, ya tienes dos implementaciones divergentes que reconciliar. Extraer proactivamente es más barato.
-
Los errores silenciosos son peores que los crashes: Un crash te dice exactamente qué falló y dónde. Un error tragado te deja con "a veces no funciona" y horas de investigación.
Qué sigue
- Tests unitarios:
TripleStore,splitMessage,mdToHtmly la lógica del pool son candidatos perfectos - Timeout en
acp.prompt(): El último punto ciego — si el agente se cuelga, no hay límite - Adapter de Slack: Con las utilidades extraídas, es cuestión de implementar
ChatAdapter
El código sigue en github.com/gouh/hive-acp.
Posts relacionados

Hive ACP v0.1.0: Orquestación Multi-Agente y Multi-Provider
Segunda parte de hive-acp: cómo evolucionó de un bridge single-agent a un sistema de orquestación multi-provider donde Kiro y OpenCode trabajan juntos, con knowledge graph, streaming adaptativo, y una arquitectura completamente agnóstica de plataforma.

Hive ACP: Mi Alternativa a OpenClaw para Conectar Agentes de IA a Telegram
Una exploración técnica de hive-acp, un bridge multi-agente que conecta un agente de IA a Telegram. Cubre la arquitectura, los protocolos ACP y MCP, persistencia de contexto, y las lecciones aprendidas después de dos intentos fallidos.

Scaffolding Typescript API - Parte 1
¿Cómo crear una estructura base de una API con typescript?