← Todos los posts

De un ticket de Jira a un PR: el sistema autónomo de punta a punta

Cómo convertí el agente en un sistema autónomo: un ticket de Jira lo dispara (webhook o polling), una cola con estado persistente lo procesa una sola vez y devuelve un PR y un comentario en Jira.

Ilustración del sistema de punta a punta: un ticket de Jira entra por un disparador, pasa por una cola con estado, se procesa en un workspace efímero y sale como un pull request que vuelve como comentario a Jira

Todos los posts anteriores construyeron el trabajador: un agent loop con herramientas que corre aislado en un repo real, reproduce el bug con un test y entrega un pull request. Lo que faltaba es lo que lo arranca y lo que reporta al terminar. Este post es ese envoltorio: hacer que un ticket de Jira dispare todo el sistema sin que nadie apriete un botón, procesarlo una sola vez aunque el disparador se repita, correrlo en un workspace efímero por ticket, y devolver el resultado como un PR y un comentario en el propio ticket. Es donde aterriza la serie: el agente deja de ser algo que ejecutas a mano y pasa a ser un servicio que reacciona a tickets.

Resumen para perezosos
  • El disparador no ejecuta el agente: solo encola. Un webhook de Jira da latencia baja pero pierde eventos; un polling con JQL es lento pero no se pierde nada. Usa webhook para reaccionar rápido y polling como red que recupera lo que el webhook dejó pasar.
  • La propiedad que sostiene todo es la idempotencia: procesar cada ticket una sola vez. Se consigue con un estado persistente en base de datos y un reclamo atómico, no con un flag en memoria que se pierde cuando el proceso se reinicia.
  • Cada ticket corre en su propio workspace efímero (el worktree del post anterior) y la salida es un PR más un comentario en Jira con el enlace. El merge lo sigue decidiendo una persona.

En este artículo:

Qué cambia al hacerlo autónomo

Hasta el post anterior, el agente era un programa que tú invocabas: le pasabas una meta, corría en su worktree, abría el PR y terminaba. Alguien tenía que arrancarlo. Hacerlo autónomo es quitar ese arranque manual: el sistema observa una fuente de trabajo —una cola de tickets de Jira— y, cuando aparece uno marcado como listo, lo procesa solo.

El cambio no está en el agente, que sigue siendo el mismo loop con las mismas herramientas. Está en las tres piezas que lo rodean: algo que se entera de que hay trabajo (el disparador), algo que garantiza que ese trabajo se hace una vez y no se pierde si el proceso se cae (la cola con estado), y algo que devuelve el resultado a donde nació el pedido (el comentario en Jira). El agente hace el trabajo; este post construye lo que lo conecta a una entrada y una salida reales.

Antes (manual)                     Ahora (autónomo)

tú ──► runAgent(meta)              ticket Jira ──► disparador ──► cola
         │                                                          │
         ▼                                                          ▼
        PR                                              worker ──► agente ──► PR


                                                            comentario en Jira

La palabra “autónomo” asusta más de lo que debería, y también promete de más. No es que el agente decida qué hacer con el negocio; es que nadie tiene que copiar el texto del ticket y lanzar el proceso a mano. El criterio de qué se hace lo sigue poniendo una persona cuando marca el ticket, y el criterio de qué se integra lo sigue poniendo quien revisa el PR. Autónomo acá quiere decir “sin intervención en el medio”, no “sin control”.

Conviene ser honesto con lo que este sistema no es: no planifica, no prioriza, no coordina unas tareas con otras, no recuerda nada de un ticket al siguiente ni orquesta dependencias entre ellos. Es un worker que reacciona a una cola, un ticket a la vez. Todo lo que sigue es la infraestructura que hace confiable esa reacción —que el trabajo no se pierda ni se duplique—, no una inteligencia que decida por su cuenta. Si esperas planificación o coordinación, este no es ese sistema; es el escalón anterior, y es el que casi todo el mundo necesita primero.

El disparador: polling o webhook

La primera pieza es cómo se entera el sistema de que hay un ticket para trabajar. Hay dos formas, y conviene entender el trade-off antes de elegir.

El webhook es Jira empujando: configuras una URL y Jira le manda un POST cada vez que un ticket cambia. Es de latencia baja —reaccionas en segundos— y no gastas llamadas preguntando en vano. El costo es que necesitas un endpoint público y que los webhooks se pierden: si tu servidor está caído o lento cuando Jira dispara, ese evento no vuelve. Jira reintenta un par de veces y después lo descarta.

El polling es tu sistema preguntando: cada cierto tiempo lanzas una consulta JQL que trae los tickets marcados como listos y encolas los nuevos. Es simple, no necesita endpoint público y no se pierde nada, porque la próxima vuelta vuelve a ver el ticket que siga pendiente. El costo es latencia —esperas al siguiente ciclo— y llamadas que muchas veces no traen nada.

WebhookPolling
LatenciaBaja (segundos)Alta (hasta un ciclo)
Endpoint públicoNo
Pierde eventosSí (si estás caído)No
Llamadas en vanoNo
ComplejidadMediaBaja

La consulta de polling es una JQL que filtra por proyecto, por una etiqueta que marca el ticket como apto para el agente, y por estado:

project = ENG
  AND labels = agent-ready
  AND status = "To Do"
ORDER BY created ASC

La etiqueta agent-ready importa más de lo que parece: es lo que evita que cualquier ticket dispare un agente. Sin ese filtro, cada ticket nuevo gastaría una corrida del agente —y las llamadas al LLM que eso implica— sin que nadie lo haya pedido. El disparador solo mira los tickets que alguien marcó a propósito.

El poller es un ciclo que corre la JQL y encola cada ticket que todavía no conoce:

// poller.ts — pregunta a Jira cada cierto tiempo y encola lo nuevo.
import { enqueueTicket } from "./queue";

const JQL = 'project = ENG AND labels = agent-ready AND status = "To Do" ORDER BY created ASC';

async function poll() {
  const res = await fetch(
    `https://your-org.atlassian.net/rest/api/3/search?jql=${encodeURIComponent(JQL)}`,
    { headers: { Authorization: `Basic ${process.env.JIRA_AUTH}`, Accept: "application/json" } },
  );
  const { issues } = await res.json();

  // Encolar no procesa: solo deja constancia de que el ticket existe.
  // El de-dup lo resuelve la cola, así que reencolar el mismo ticket es inofensivo.
  for (const issue of issues) {
    await enqueueTicket(issue.key, issue.fields.summary);
  }
}

// Un ciclo cada 30 s: la latencia máxima del polling es ese intervalo.
setInterval(poll, 30_000);
# poller.py — pregunta a Jira cada cierto tiempo y encola lo nuevo.
import os
import time
import requests
from queue import enqueue_ticket

JQL = 'project = ENG AND labels = agent-ready AND status = "To Do" ORDER BY created ASC'

def poll() -> None:
    res = requests.get(
        "https://your-org.atlassian.net/rest/api/3/search",
        params={"jql": JQL},
        headers={"Authorization": f"Basic {os.environ['JIRA_AUTH']}", "Accept": "application/json"},
    )
    # Encolar no procesa: solo deja constancia de que el ticket existe.
    # El de-dup lo resuelve la cola, así que reencolar el mismo ticket es inofensivo.
    for issue in res.json()["issues"]:
        enqueue_ticket(issue["key"], issue["fields"]["summary"])

# Un ciclo cada 30 s: la latencia máxima del polling es ese intervalo.
while True:
    poll()
    time.sleep(30)
<?php
// poller.php — pregunta a Jira cada cierto tiempo y encola lo nuevo.
require "queue.php"; // enqueue_ticket()

const JQL = 'project = ENG AND labels = agent-ready AND status = "To Do" ORDER BY created ASC';

function poll(): void {
    $url = "https://your-org.atlassian.net/rest/api/3/search?jql=" . urlencode(JQL);
    $ctx = stream_context_create(["http" => ["header" =>
        "Authorization: Basic " . getenv("JIRA_AUTH") . "\r\nAccept: application/json"]]);
    $data = json_decode(file_get_contents($url, false, $ctx), true);

    // Encolar no procesa: solo deja constancia de que el ticket existe.
    // El de-dup lo resuelve la cola, así que reencolar el mismo ticket es inofensivo.
    foreach ($data["issues"] as $issue) {
        enqueue_ticket($issue["key"], $issue["fields"]["summary"]);
    }
}

// Un ciclo cada 30 s: la latencia máxima del polling es ese intervalo.
while (true) {
    poll();
    sleep(30);
}

El webhook usa exactamente el mismo enqueueTicket: un handler HTTP que valida la firma del evento, saca la clave del ticket del payload y encola. Por eso no compiten entre sí. Que convenga tener los dos depende de tu contexto operativo: si tu endpoint puede estar caído cuando Jira dispara, o no confías en que el proveedor reintente hasta entregar, combinar webhook y polling reduce el riesgo de perder eventos —el webhook para reaccionar en segundos, y el polling corriendo cada pocos minutos como red que recupera lo que el webhook dejó pasar—. No es una regla universal: hay sistemas donde un webhook con reintentos y el evento persistido en cuanto llega alcanzan de sobra. Pero si tienes que elegir uno solo por simplicidad, el polling es el que no pierde trabajo, y perder trabajo suele ser peor que reaccionar tarde.

La cola y el estado persistente

Acá está la decisión que casi todo el mundo hace mal la primera vez: el disparador no ejecuta el agente. Solo encola. La tentación es que el handler del webhook corra el agente ahí mismo y responda cuando termine, pero eso rompe por dos lados. Primero, una tarea de agente tarda minutos, y un webhook tiene que responder en segundos o Jira lo da por fallido y reintenta —disparando el trabajo de nuevo—. Segundo, si el proceso se reinicia a mitad de una tarea que solo vivía en memoria, ese trabajo se pierde sin que quede rastro.

La separación es la solución: el disparador escribe una fila en una tabla, y un worker aparte la toma y la procesa. La tabla es la cola, y el estado de cada ticket vive ahí, no en la memoria de un proceso. Si todo se reinicia, el estado sigue en la base de datos y el worker retoma donde quedó.

-- La cola es una tabla. El estado de cada ticket vive acá, no en memoria.
CREATE TABLE tickets (
  jira_key    TEXT PRIMARY KEY,          -- ENG-1234: uno por ticket, sin duplicados
  summary     TEXT NOT NULL,
  status      TEXT NOT NULL DEFAULT 'queued', -- queued | running | pr_open | failed
  attempts    INT  NOT NULL DEFAULT 0,   -- para cortar los tickets que siempre fallan
  pr_url      TEXT,                       -- se llena cuando el agente abre el PR
  claimed_at  TIMESTAMPTZ,               -- cuándo lo tomó un worker (para detectar cuelgues)
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

jira_key como clave primaria es la mitad del de-dup: encolar dos veces el mismo ticket no crea dos filas, la segunda inserción choca con la clave y no hace nada. Encolar, entonces, es un INSERT que ignora el conflicto:

// queue.ts — encolar es idempotente: la clave primaria absorbe los duplicados.
import { db } from "./db";

export async function enqueueTicket(jiraKey: string, summary: string) {
  // Si el ticket ya está en la cola, ON CONFLICT lo deja como está.
  // Reencolar desde el polling o un webhook repetido no crea trabajo duplicado.
  await db.query(
    `INSERT INTO tickets (jira_key, summary)
     VALUES ($1, $2)
     ON CONFLICT (jira_key) DO NOTHING`,
    [jiraKey, summary],
  );
}
# queue.py — encolar es idempotente: la clave primaria absorbe los duplicados.
from db import db

def enqueue_ticket(jira_key: str, summary: str) -> None:
    # Si el ticket ya está en la cola, ON CONFLICT lo deja como está.
    # Reencolar desde el polling o un webhook repetido no crea trabajo duplicado.
    db.execute(
        """INSERT INTO tickets (jira_key, summary)
           VALUES (%s, %s)
           ON CONFLICT (jira_key) DO NOTHING""",
        (jira_key, summary),
    )
<?php
// queue.php — encolar es idempotente: la clave primaria absorbe los duplicados.
require "db.php"; // expone $db (PDO)

function enqueue_ticket(string $jiraKey, string $summary): void {
    // Si el ticket ya está en la cola, ON CONFLICT lo deja como está.
    // Reencolar desde el polling o un webhook repetido no crea trabajo duplicado.
    global $db;
    $stmt = $db->prepare(
        "INSERT INTO tickets (jira_key, summary)
         VALUES (?, ?)
         ON CONFLICT (jira_key) DO NOTHING"
    );
    $stmt->execute([$jiraKey, $summary]);
}

Con esto, el disparador puede reencolar el mismo ticket todas las veces que quiera —el polling lo hará cada 30 segundos hasta que cambie de estado— sin ensuciar la cola. El estado avanza en un solo sentido: queuedrunningpr_open, o failed si se agotaron los intentos.

Idempotencia: no procesar el mismo ticket dos veces

La clave primaria evita encolar dos veces, pero falta la otra mitad: evitar que dos workers tomen el mismo ticket a la vez. Con polling y webhook alimentando la cola, y quizá varios workers corriendo en paralelo, esto no es un caso raro, es lo normal. Si dos workers leen la cola en el mismo instante, los dos ven el ticket en queued y los dos lo procesan: dos worktrees, dos PRs, dos comentarios en Jira para el mismo bug.

La solución no es un flag ni un candado en memoria —eso se pierde en el reinicio y no cruza entre procesos—. Es un reclamo atómico: una sola consulta que pasa el ticket de queued a running y, en el mismo paso, te lo devuelve. La base de datos garantiza que ese UPDATE lo gana un solo worker; el otro no encuentra ninguna fila en queued que coincida y sigue de largo.

-- Reclamo atómico: pasar de queued a running y devolver el ticket, en un solo paso.
-- La BD garantiza que solo UN worker gana este UPDATE; los demás no ven la fila.
UPDATE tickets
   SET status = 'running',
       attempts = attempts + 1,
       claimed_at = now()
 WHERE jira_key = (
     SELECT jira_key FROM tickets
      WHERE status = 'queued'
      ORDER BY created_at ASC
      FOR UPDATE SKIP LOCKED   -- no esperes al que ya está reclamando otro worker
      LIMIT 1
   )
RETURNING jira_key, summary;

El FOR UPDATE SKIP LOCKED es la pieza fina: bloquea la fila que este worker va a reclamar y hace que cualquier otro worker que llegue en paralelo la salte en vez de esperarla. Así N workers reclaman N tickets distintos sin pisarse y sin quedarse esperando uno al otro. El worker envuelve ese reclamo en su ciclo: toma un ticket, lo procesa, y si no hay nada, espera un momento y vuelve a intentar.

// worker.ts — reclama un ticket, lo procesa, repite. Uno a la vez por worker.
import { db } from "./db";
import { processTicket } from "./process";

const CLAIM = `
  UPDATE tickets SET status='running', attempts=attempts+1, claimed_at=now()
   WHERE jira_key = (
     SELECT jira_key FROM tickets WHERE status='queued'
      ORDER BY created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1)
  RETURNING jira_key, summary`;

async function workerLoop() {
  for (;;) {
    const { rows } = await db.query(CLAIM);
    if (rows.length === 0) {
      await new Promise((r) => setTimeout(r, 5_000)); // cola vacía: espera y reintenta
      continue;
    }
    // Ganamos el reclamo: este ticket es nuestro y de nadie más.
    await processTicket(rows[0].jira_key, rows[0].summary);
  }
}

workerLoop();
# worker.py — reclama un ticket, lo procesa, repite. Uno a la vez por worker.
import time
from db import db
from process import process_ticket

CLAIM = """
  UPDATE tickets SET status='running', attempts=attempts+1, claimed_at=now()
   WHERE jira_key = (
     SELECT jira_key FROM tickets WHERE status='queued'
      ORDER BY created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1)
  RETURNING jira_key, summary"""

def worker_loop() -> None:
    while True:
        rows = db.query(CLAIM)
        if not rows:
            time.sleep(5)  # cola vacía: espera y reintenta
            continue
        # Ganamos el reclamo: este ticket es nuestro y de nadie más.
        process_ticket(rows[0]["jira_key"], rows[0]["summary"])

worker_loop()
<?php
// worker.php — reclama un ticket, lo procesa, repite. Uno a la vez por worker.
require "db.php";      // $db (PDO)
require "process.php"; // process_ticket()

const CLAIM = "
  UPDATE tickets SET status='running', attempts=attempts+1, claimed_at=now()
   WHERE jira_key = (
     SELECT jira_key FROM tickets WHERE status='queued'
      ORDER BY created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1)
  RETURNING jira_key, summary";

function worker_loop(): void {
    global $db;
    while (true) {
        $row = $db->query(CLAIM)->fetch(PDO::FETCH_ASSOC);
        if (!$row) {
            sleep(5); // cola vacía: espera y reintenta
            continue;
        }
        // Ganamos el reclamo: este ticket es nuestro y de nadie más.
        process_ticket($row["jira_key"], $row["summary"]);
    }
}

worker_loop();

Vale ser preciso con lo que esto garantiza y lo que no. El disparador entrega al menos una vez: el webhook reintenta y el polling vuelve a ver el ticket, así que el mismo trabajo puede llegar varias veces. Lo que este diseño consigue no es exactly-once —esa entrega, en la práctica, no existe—, sino entrega al menos una vez más un efecto idempotente: el ticket se puede encolar y reclamar muchas veces, pero se procesa una sola. La diferencia importa cuando el efecto sale del sistema —abrir un PR, comentar en Jira—, porque esos pasos también tienen que ser idempotentes: “una sola vez” se sostiene en que el efecto se aplique una vez, no en que el mensaje llegue una vez.

El campo claimed_at cubre el caso feo: un worker reclama un ticket, lo pasa a running y se cae antes de terminar. Ese ticket queda en running para siempre y nadie lo vuelve a tomar, porque el reclamo solo mira los queued. La red es un barrido periódico que devuelve a queued los tickets que llevan demasiado tiempo en running —un reclamo vencido—, para que otro worker los retome. Es el equivalente a un lease con vencimiento: reclamar un ticket no es para siempre, es por un rato.

Ese barrido tiene un filo que conviene ver: si devuelves a queued un ticket cuyo worker en realidad seguía vivo —solo lento—, terminas con dos workers sobre el mismo ticket. Por eso el lease no debería ser un timeout fijo y ciego. El worker que procesa una tarea larga renueva su reclamo mientras trabaja —un heartbeat que empuja claimed_at hacia adelante cada tanto—, y el barrido solo recupera los tickets cuyo reclamo venció de verdad porque el worker dejó de latir. Aun así, la recuperación puede solaparse con un worker rezagado, y es otra razón por la que el efecto final —el PR, el comentario— tiene que ser idempotente: es la última red para cuando el lease se equivoca.

El workspace efímero por ticket

Con el ticket reclamado, el procesamiento en sí es casi todo lo que ya construyó el post anterior: cada ticket recibe su propio workspace aislado —un git worktree sobre una rama nueva—, el agente trabaja ahí dentro, y al terminar la rama tiene los commits listos para el PR. El jira_key es el identificador que mantiene todo separado: la rama es agent/ENG-1234, el worktree es una carpeta con ese nombre, y el PR lleva la clave del ticket en el título.

“Efímero” es la palabra que importa. El workspace se crea cuando el worker reclama el ticket y se destruye cuando el PR ya está abierto; no queda nada entre un ticket y el siguiente. Eso da dos cosas: dos tickets que corren a la vez no se ven —cada uno en su worktree—, y un ticket que sale mal no deja basura, porque su workspace se borra entero. Es el mismo aislamiento del post anterior, ahora disparado por la cola en vez de por tu mano.

Lo que muestro es el esqueleto del workspace. Un sistema real le suma alrededor lo que toda corrida de verdad necesita y que aquí omito para no perder el hilo del disparador y la cola: caché de dependencias entre tickets, logs de cada corrida, artefactos de la build, reportes de cobertura, y la limpieza de todo eso cuando el ticket termina.

worker reclama ENG-1234


crea worktree + rama agent/ENG-1234   ◄── aislamiento del post 6


corre el agente (write-test-fix, suite completa)   ◄── el loop de la serie


abre el PR ──► borra el worktree local ──► la rama queda, el workspace no

Una capa que en un sistema real no es opcional: ese worktree debería correr dentro de un contenedor efímero, no directo sobre la máquina del worker. El agente ejecuta comandos y código que salieron de un modelo a partir del texto de un ticket que escribió cualquiera; el sandbox es lo que evita que un ticket malicioso —o simplemente un comando equivocado— toque algo fuera de su caja. La cola trae trabajo de una fuente que no controlas del todo, así que el aislamiento del proceso deja de ser un lujo.

Abrir el PR y devolver el resultado a Jira

El agente abre el PR igual que en el post anterior: sube la rama y crea el pull request con la clave del ticket en el título. Lo nuevo del sistema autónomo es el último paso —el que hace que sea un sistema y no un proceso suelto—: el resultado vuelve a Jira. El agente escribe un comentario en el ticket con el enlace al PR y mueve el ticket a “In Review”, para que la persona que lo abrió vea el resultado donde lo pidió, sin tener que ir a buscarlo a GitHub.

Son dos llamadas a la API de Jira: una publica el comentario, otra transiciona el estado del ticket. La transición usa un id numérico que depende de cómo esté configurado tu flujo de trabajo en Jira, no el nombre del estado:

// report.ts — el resultado vuelve a Jira: comentario con el PR + cambio de estado.
const JIRA = "https://your-org.atlassian.net/rest/api/3";
const headers = {
  Authorization: `Basic ${process.env.JIRA_AUTH}`,
  "Content-Type": "application/json",
};

export async function reportToJira(jiraKey: string, prUrl: string) {
  // 1. Comentario con el enlace al PR, donde la persona que abrió el ticket lo ve.
  await fetch(`${JIRA}/issue/${jiraKey}/comment`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      body: { type: "doc", version: 1, content: [{ type: "paragraph",
        content: [{ type: "text", text: `PR listo para revisar: ${prUrl}` }] }] },
    }),
  });

  // 2. Mueve el ticket a "In Review". El id de transición sale de tu flujo en Jira.
  await fetch(`${JIRA}/issue/${jiraKey}/transitions`, {
    method: "POST",
    headers,
    body: JSON.stringify({ transition: { id: "31" } }),
  });
}
# report.py — el resultado vuelve a Jira: comentario con el PR + cambio de estado.
import os
import requests

JIRA = "https://your-org.atlassian.net/rest/api/3"
headers = {
    "Authorization": f"Basic {os.environ['JIRA_AUTH']}",
    "Content-Type": "application/json",
}

def report_to_jira(jira_key: str, pr_url: str) -> None:
    # 1. Comentario con el enlace al PR, donde la persona que abrió el ticket lo ve.
    requests.post(
        f"{JIRA}/issue/{jira_key}/comment",
        headers=headers,
        json={"body": {"type": "doc", "version": 1, "content": [{"type": "paragraph",
            "content": [{"type": "text", "text": f"PR listo para revisar: {pr_url}"}]}]}},
    )

    # 2. Mueve el ticket a "In Review". El id de transición sale de tu flujo en Jira.
    requests.post(
        f"{JIRA}/issue/{jira_key}/transitions",
        headers=headers,
        json={"transition": {"id": "31"}},
    )
<?php
// report.php — el resultado vuelve a Jira: comentario con el PR + cambio de estado.
const JIRA = "https://your-org.atlassian.net/rest/api/3";

function jira_post(string $path, array $body): void {
    $ctx = stream_context_create(["http" => [
        "method" => "POST",
        "header" => "Authorization: Basic " . getenv("JIRA_AUTH") .
                    "\r\nContent-Type: application/json",
        "content" => json_encode($body),
    ]]);
    file_get_contents(JIRA . $path, false, $ctx);
}

function report_to_jira(string $jiraKey, string $prUrl): void {
    // 1. Comentario con el enlace al PR, donde la persona que abrió el ticket lo ve.
    jira_post("/issue/$jiraKey/comment", ["body" => ["type" => "doc", "version" => 1,
        "content" => [["type" => "paragraph",
            "content" => [["type" => "text", "text" => "PR listo para revisar: $prUrl"]]]]]]);

    // 2. Mueve el ticket a "In Review". El id de transición sale de tu flujo en Jira.
    jira_post("/issue/$jiraKey/transitions", ["transition" => ["id" => "31"]]);
}

Con el comentario publicado y el ticket en “In Review”, el worker marca el ticket como pr_open en su propia tabla y guarda el pr_url. Ese es el estado terminal del lado del agente: hizo su parte y devolvió el resultado. Lo que sigue —revisar el PR, pedir cambios, mergear— es trabajo humano, y es a propósito. El post anterior ya defendió por qué la salida es un PR y no un merge: los tests verdes no garantizan que el cambio sea correcto, solo que no rompió lo que sabes verificar. El comentario en Jira no cambia eso; solo hace que el punto de control humano esté donde la gente ya mira.

El diagrama de punta a punta

Con todas las piezas en su lugar, este es el sistema completo, de un ticket a un PR, con cada etapa apoyada en un post de la serie:

  ┌─────────────┐
  │ Ticket Jira │  alguien lo marca con la etiqueta agent-ready
  └──────┬──────┘

   ┌─────┴─────┐  DISPARADOR
   │  webhook  │  (latencia baja, pierde eventos)
   │     +     │
   │  polling  │  (red que recupera lo perdido)
   └─────┬─────┘
         │  enqueueTicket()  → INSERT ON CONFLICT DO NOTHING

  ┌─────────────┐  COLA + ESTADO (tabla tickets)
  │   queued    │  el estado vive en la BD, no en memoria
  └──────┬──────┘
         │  reclamo atómico (UPDATE ... FOR UPDATE SKIP LOCKED)

  ┌─────────────┐  un solo worker gana el ticket → idempotencia
  │   running   │
  └──────┬──────┘
         │  worktree + rama agent/ENG-1234        ◄── post 6: aislamiento
         │  contenedor efímero                    ◄── post 3: sandbox
         │  write-test-fix + suite completa       ◄── posts 2 y 4

  ┌─────────────┐  abre el PR (no merge)          ◄── post 6: PR como salida
  │   pr_open   │  comentario en Jira + "In Review"
  └──────┬──────┘


   revisión humana ──► merge ──► cleanup

Leído de arriba abajo, cada franja es una decisión que la serie fue construyendo: el loop y su criterio de terminado, el sandbox, el evaluador que define qué es correcto, el aislamiento por tarea y el PR como salida. Este post agregó las tres franjas de los extremos —el disparador, la cola con estado y el retorno a Jira— que son las que convierten un agente que ejecutas a mano en un sistema que reacciona a tickets solo.

Lo que más cambia al completar el sistema no es que el agente sea más listo, es que deja de necesitarte para arrancar. Un ticket marcado por la mañana puede tener un PR abierto y comentado antes de que alguien lo mire, y si el proceso se reinició tres veces en el medio, el estado en la tabla hizo que el trabajo se hiciera una sola vez.

Dónde se rompe

El sistema es sólido, pero tiene aristas que conviene conocer antes de soltarlo sobre una cola real:

  • No todo ticket debe disparar un agente. Sin el filtro por etiqueta, cada ticket nuevo gasta una corrida del agente y sus llamadas al LLM. La etiqueta agent-ready es lo que mantiene el costo bajo control; combínalo con el routing de modelos para que además cada corrida use el modelo más barato que resuelva la tarea.
  • Los tickets veneno bloquean o gastan sin fin. Un ticket que el agente nunca logra resolver se reintentaría para siempre si no cortas. El contador attempts es el freno: pasado un límite, el ticket va a failed en vez de volver a la cola, y alguien recibe un aviso. Es una cola de descarte, no un reintento infinito.
  • No todos los errores merecen el mismo reintento. El contador attempts de este diseño trata igual a un 429 del LLM, a un timeout de red y a un ticket cuya tarea es imposible o está mal escrita. Los dos primeros son transitorios y se resuelven reintentando —idealmente con un backoff—; el tercero no se arregla repitiéndolo y debería ir directo a failed. Un reintento que no distingue el tipo de error gasta corridas de agente en tareas que nunca van a pasar. Clasificar el fallo antes de decidir si reintentas es el siguiente paso del contador simple.
  • El webhook pierde eventos y el polling llega tarde. Ninguno de los dos es completo solo. Por eso van juntos: el webhook da la latencia y el polling la garantía. Si montas solo el webhook, tarde o temprano un ticket se queda sin procesar y nadie se entera.
  • La concurrencia la limitan los recursos, no la cola. El reclamo atómico deja correr N workers sin que se pisen, pero cada uno reinstala dependencias y corre la suite completa, igual que en el post anterior. El techo de cuántos tickets procesas a la vez es la máquina, no el SKIP LOCKED.
  • El rate limit que importa casi nunca es el de Jira. Un poller agresivo o muchos workers comentando a la vez pueden chocar con la API de Jira, sí, pero el cuello de botella real suele estar en el LLM, en la API de GitHub y en el CI —ahí es donde muchos workers en paralelo topan con los límites de tasa y las colas de build—. Espacia el polling y agrupa las escrituras a Jira, pero sobre todo controla la concurrencia contra el modelo y el CI, que es donde se acumula el costo y la espera.
  • El agente no tiene estado propio. El ticket tiene estado y la cola tiene estado, pero el agent loop no: si se cae a mitad de una corrida —después de varias herramientas y varias llamadas al modelo—, no hay checkpoint ni journal desde donde retomar. El ticket vuelve a queued y la tarea arranca de cero. Para tareas cortas es aceptable; para tareas largas y caras, un punto de reanudación es lo siguiente que vas a extrañar.
  • Sin observabilidad, el sistema es una caja negra. Este diseño no incluye métricas, logs estructurados ni tracing, y en cuanto haya más de un worker vas a querer responder preguntas que hoy no puede: qué ticket está corriendo y hace cuánto, qué worker lo tiene, cuántos fallaron y por qué, cuántos tokens costó cada corrida, qué herramienta falló. Es el primer agregado que pide un sistema como este en cuanto pasa de un ejemplo a algo que opera solo.
  • Los secretos viajan a cada workspace. Igual que con el worktree del post anterior, credenciales y tokens en cada copia amplían la superficie de exposición. Inyecta lo mínimo y nunca los dejes en un commit de la rama.
  • La revisión humana sigue siendo el punto de control. El sistema abre el PR y avisa; no juzga si el cambio es correcto. Si el PR se mergea sin leer porque “los tests pasan”, perdiste justo la pieza que el diseño reservó para una persona.

Ninguna invalida el modelo; lo acotan. El sistema propone trabajo de punta a punta sin intervención en el medio, pero la decisión de qué se pide y qué se integra sigue siendo humana.

Preguntas frecuentes

¿Webhook o polling para disparar el agente?

Los dos, si puedes. El webhook te da latencia baja —reaccionas en segundos— pero pierde eventos si tu servidor está caído cuando Jira dispara. El polling con JQL es lento pero no se pierde nada, porque la próxima vuelta vuelve a ver el ticket pendiente. La combinación robusta es webhook para reaccionar rápido y polling cada pocos minutos como red que recupera lo perdido. Si solo montas uno, que sea el polling: perder trabajo es peor que reaccionar tarde.

¿Qué pasa si el worker se cae a mitad de un ticket?

Nada se pierde, porque el estado vive en la base de datos, no en el proceso. El ticket queda en running con su claimed_at, y un barrido periódico devuelve a queued los reclamos vencidos —los que llevan demasiado tiempo en running— para que otro worker los retome. Es un lease con vencimiento: reclamar un ticket no es para siempre. Para que un worker lento no se confunda con uno caído, el reclamo se renueva con un heartbeat mientras la tarea avanza; y como último resguardo por si el barrido se adelanta, el efecto final —el PR y el comentario— es idempotente. La combinación de estado persistente y reclamo atómico es lo que hace que el trabajo se procese una sola vez aunque el proceso se reinicie.

¿Garantiza procesar cada ticket exactly-once?

No, y conviene no venderlo así. El disparador entrega al menos una vez —el webhook reintenta, el polling vuelve a ver el ticket—, y eso no se puede evitar. Lo que el diseño consigue es un efecto idempotente sobre esa entrega: la clave primaria absorbe los encolados duplicados y el reclamo atómico deja que un solo worker procese cada ticket. El resultado práctico es “se procesa una sola vez”, pero el mecanismo es entrega al menos una vez más idempotencia, no entrega exactly-once. La distinción importa cuando el efecto sale del sistema: abrir el PR y comentar en Jira también deberían ser idempotentes para que un reintento no cree un segundo PR.

¿Cómo evito que cualquier ticket dispare un agente?

Con un filtro explícito en el disparador: una etiqueta como agent-ready en la JQL del polling y en la condición del webhook. Solo los tickets que alguien marcó a propósito entran a la cola. Sin ese filtro, cada ticket nuevo gastaría una corrida del agente sin que nadie lo haya pedido, y eso se nota en la factura de LLM.

¿El agente mergea el PR solo?

No. Su estado terminal es pr_open: abre el pull request, comenta el enlace en Jira y mueve el ticket a “In Review”. Ahí se detiene. Revisar, pedir cambios y mergear es trabajo humano, por la misma razón que defendí en el post anterior: los tests verdes prueban que no rompiste lo conocido, no que el cambio sea correcto. El sistema automatiza todo hasta el punto de control, no el punto de control.

¿Sirve solo para Jira?

No. La cola, el worker con reclamo atómico y el workspace efímero son iguales para cualquier fuente de tickets. Lo único específico de Jira es el disparador —la JQL o el webhook— y el retorno —el comentario y la transición—. Cambiar Jira por GitHub Issues, Linear o Asana es reescribir esas dos puntas; el núcleo del sistema no se toca.

¿Necesito una cola de verdad tipo Redis o SQS?

Para empezar, no. Una tabla en Postgres con un INSERT ON CONFLICT para encolar y un UPDATE ... FOR UPDATE SKIP LOCKED para reclamar te da de-dup, estado persistente y reclamo atómico sin una pieza más de infraestructura. Una cola dedicada gana cuando el volumen crece o necesitas reintentos y prioridades más finos, pero mientras la tabla te alcance, es una dependencia menos que operar.

Conclusión

La serie entera construyó el trabajador: un loop con herramientas, un sandbox, un evaluador que define qué es correcto, aislamiento por tarea y un PR como salida. Este post no lo hizo más listo; le puso alrededor lo que lo convierte en un sistema autónomo. Un disparador que se entera del trabajo sin que nadie lo arranque —webhook para la latencia, polling para la garantía—. Una cola con estado persistente y un reclamo atómico, que es lo que hace que cada ticket se procese una sola vez aunque todo se reinicie. Un workspace efímero por ticket, el mismo aislamiento de siempre pero disparado por la cola. Y un retorno a Jira, para que el resultado aparezca donde nació el pedido.

Si vas a construirlo, empieza por lo más chico que ya es correcto: una tabla como cola, polling con un filtro por etiqueta, un solo worker con reclamo atómico, y como salida un PR más un comentario en Jira. Con eso tienes el sistema de punta a punta funcionando y una sola cosa que hace un humano —marcar el ticket— y otra al final —revisar el PR—. Después le agregas el webhook para la latencia, más workers para el volumen y el barrido de reclamos vencidos para los cuelgues. La autonomía no fue un agente más inteligente; fue la infraestructura que lo conectó a un ticket por un lado y a un PR por el otro.

Seguir leyendo