Volver al blog

Hive ACP v0.2.0: Notificaciones que no se pierden

por Hugo Hernández Valdez

Hive ACP v0.2.0: Notificaciones que no se pierden

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étricav0.1.0v0.2.0
Adapter LOC~590~460 (-22%)
Bugs de tipado posibles en eventos0 (compile-time)
Notificaciones perdidas por busyFrecuenteEliminado
Tiempo máximo de espera para drain30s (luego se pierde)Hasta que termine (event-driven)
Módulos reutilizables para adapters02
Dead code2 variables0
Errores silenciosos en promptLockTodos0 (logueados)

Lecciones

  1. Event-driven > polling: Si estás haciendo setTimeout en 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.

  2. 40 líneas de infraestructura ahorran horas de debugging: TypedEmitter es 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.

  3. 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.

  4. 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, mdToHtml y 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.

Compartir

Posts relacionados