← Todos los posts

El agente dentro de un repo real: aislar tareas con git worktree y entregar un PR

Cómo poner un agent loop a trabajar sobre un repositorio real: aislar cada tarea con git worktree, reproducir el bug con un test, correr toda la suite y entregar un PR en vez de un merge.

Ilustración de un agente aislado: del repositorio sale una copia de trabajo separada sobre su propia rama, el loop trabaja ahí dentro y la salida es un pull request, no un merge

El post anterior le dio al agente herramientas para leer, buscar, editar y correr sobre un repositorio de varios archivos, pero corriendo directamente sobre la misma copia de trabajo en la que trabajas tú. Sirve para entender el loop; no para soltarlo sobre un repo de verdad. Este post es el envoltorio que falta: dónde corre el agente cuando el repositorio importa. La idea es darle a cada tarea una copia aislada —con git worktree y una rama propia—, hacer que reproduzca el bug con un test antes de tocar nada, que corra toda la suite para no romper lo viejo, y que su salida sea un pull request, no un merge.

Resumen para perezosos
  • No dejes que el agente corra sobre tu copia de trabajo. Dale a cada tarea su propia copia aislada con git worktree y una rama: un intento fallido es una rama que borras, y dos tareas a la vez no se pisan.
  • El flujo que funciona con bugs es escribir primero el test que reproduce el fallo (rojo), arreglar hasta ponerlo verde, y al final correr TODA la suite para cazar regresiones. Que el test nuevo pase no basta.
  • La salida del agente es un PR, no un merge. Propone un diff en su propia rama; una persona lo revisa y lo integra. Aislar el trabajo y entregarlo como PR es lo que te deja dejarlo correr sin vigilarlo línea por línea.

En este artículo:

Por qué el agente no debe tocar tu copia de trabajo

Las herramientas del post anterior —write_file y run_command— operan sobre el sistema de archivos: escriben archivos reales y ejecutan comandos reales. Mientras el repo es un ejemplo de juguete, da igual dónde corran. En un repositorio de verdad, el lugar donde corren es la primera decisión seria, porque el agente equivoca pasos: abre el archivo que no era, deja un cambio a medias, corre un comando que falla. Si todo eso pasa sobre tu copia de trabajo activa —el working tree, los archivos que tienes abiertos ahora mismo—, el daño es directo.

Tres problemas aparecen de inmediato. El primero es que el trabajo del agente se mezcla con el tuyo: si tenías cambios sin commitear, ahora no sabes cuáles líneas son tuyas y cuáles las puso el agente. El segundo es que un intento abortado a media edición deja archivos rotos en tu árbol, y limpiar eso a mano es justo lo que querías evitar. El tercero es que no puedes correr dos tareas a la vez, porque ambas escribirían sobre los mismos archivos.

Sobre tu copia de trabajo             Aislado por tarea
(mal)                                 (bien)

tu trabajo  ─┐                        tu trabajo ─► copia principal (intacta)
agente t1   ─┼─► mismo árbol          agente t1  ─► worktree 1 + rama agent/t1
agente t2   ─┘   (se pisan)           agente t2  ─► worktree 2 + rama agent/t2

La solución es la misma que usa un equipo humano para no pisarse: cada tarea trabaja sobre su propia copia y su propia rama. Lo único distinto es que aquí la copia la crea y la destruye el programa, no una persona.

Clon nuevo o git worktree: dónde corre el agente

Las dos formas de aislar cada tarea son un clon nuevo y un git worktree; las dos aíslan, lo que cambia es el costo. Un clon nuevo (git clone en un directorio temporal por tarea) es fácil de razonar —un repo entero e independiente, con su propio .git— pero pesado: copia todo el historial y obliga a reinstalar dependencias cada vez.

git worktree, el que usé, es casi instantáneo porque no duplica el repositorio: todos los worktrees comparten la misma base de objetos (.git/objects), las refs, los hooks y la configuración, y cada uno solo materializa otro directorio de trabajo sobre una rama distinta. La contrapartida es que el worktree no es un repositorio independiente —sigue atado al .git original—. Aísla la copia de trabajo, no el repositorio Git por debajo.

Clon nuevogit worktree
Aísla la copia de trabajo
Comparte objetos .gitNo (copia todo)Sí (un solo .git)
Costo de crearAlto (clona el repo entero)Bajo (solo la copia de trabajo)
Dependencias (node_modules)ReinstalaReinstala (no se comparten)
Varias tareas a la vezSí, pero pesadoSí, liviano

Ni el clon ni el worktree comparten node_modules —vive en la copia de trabajo, no en .git—, así que cada copia paga su instalación, aunque las cachés de los gestores modernos (el store de pnpm, el caché de npm, el caché de dependencias en CI) amortiguan bastante el golpe. Y ese costo, con todo, suele ser menor que el que de verdad domina una tarea de agente: las llamadas al LLM, la corrida de la suite y el pipeline de CI. Clonar o crear el worktree casi nunca pasa del 1% del tiempo total de una tarea; el resto de este post trata sobre cómo administrar bien esa fracción pequeña, no sobre el cuello de botella real.

Una rama por tarea con git worktree

El mecanismo es una sola orden de git: crear un worktree sobre una rama nueva. El agente trabaja dentro de esa carpeta, y al terminar la rama tiene todos sus commits, lista para revisar. Si la tarea sale mal, se descarta entera y tu repositorio nunca se enteró.

# Una copia de trabajo separada para la tarea, sobre su propia rama.
git worktree add -b agent/fix-users ../wt-fix-users HEAD

# El agente trabaja dentro de ../wt-fix-users; tu copia de trabajo principal no se toca.
# Al terminar, la rama agent/fix-users tiene los commits de la tarea.

# Si la tarea sale mal, se descarta sin dejar rastro:
git worktree remove --force ../wt-fix-users
git branch -D agent/fix-users

En el orquestador, eso es una función que envuelve al loop del post anterior: prepara el worktree, corre el agente con ese directorio como raíz, y limpia si algo falla. El loop en sí no cambia —es el mismo ReAct con sus cuatro herramientas—; lo único nuevo es que ahora vive dentro de un espacio aislado y desechable.

// orchestrator.ts — una tarea = un worktree aislado + su propia rama.
import { execFileSync } from "node:child_process";
import { runAgent } from "./agent"; // el loop ReAct del post anterior

const git = (cwd: string, ...args: string[]) =>
  execFileSync("git", args, { cwd, encoding: "utf8" });

export async function runTask(repoDir: string, taskId: string, goal: string) {
  const branch = `agent/${taskId}`;
  const worktree = `${repoDir}/../wt-${taskId}`;

  git(repoDir, "worktree", "add", "-b", branch, worktree, "HEAD");
  try {
    await runAgent(goal, worktree);
    return { branch, worktree }; // listo para revisar y abrir el PR
  } catch (err) {
    git(repoDir, "worktree", "remove", "--force", worktree); // intento fallido, se descarta
    throw err;
  }
}
# orchestrator.py — una tarea = un worktree aislado + su propia rama.
import subprocess
from agent import run_agent  # el loop ReAct del post anterior

def git(cwd: str, *args: str) -> str:
    return subprocess.run(["git", *args], cwd=cwd, check=True,
                          capture_output=True, text=True).stdout

def run_task(repo_dir: str, task_id: str, goal: str) -> dict:
    branch = f"agent/{task_id}"
    worktree = f"{repo_dir}/../wt-{task_id}"

    git(repo_dir, "worktree", "add", "-b", branch, worktree, "HEAD")
    try:
        run_agent(goal, worktree)
        return {"branch": branch, "worktree": worktree}  # listo para revisar y abrir el PR
    except Exception:
        git(repo_dir, "worktree", "remove", "--force", worktree)  # intento fallido, se descarta
        raise
<?php
// orchestrator.php — una tarea = un worktree aislado + su propia rama.
require "agent.php"; // run_agent(): el loop ReAct del post anterior

function git(string $cwd, string ...$args): string {
    $cmd = "git " . implode(" ", array_map("escapeshellarg", $args));
    return shell_exec("cd " . escapeshellarg($cwd) . " && $cmd 2>&1") ?? "";
}

function run_task(string $repoDir, string $taskId, string $goal): array {
    $branch = "agent/$taskId";
    $worktree = "$repoDir/../wt-$taskId";

    git($repoDir, "worktree", "add", "-b", $branch, $worktree, "HEAD");
    try {
        run_agent($goal, $worktree);
        return ["branch" => $branch, "worktree" => $worktree]; // listo para el PR
    } catch (\Throwable $e) {
        git($repoDir, "worktree", "remove", "--force", $worktree); // intento fallido, se descarta
        throw $e;
    }
}

El taskId mantiene las tareas separadas: la rama, la carpeta del worktree y el PR llevan ese identificador. Lanzar cinco tareas a la vez son cinco worktrees independientes que no se pisan mientras trabajan.

Lo que el worktree no resuelve es qué pasa si dos tareas tocan el mismo archivo. Si la tarea A y la tarea B parten del mismo commit y ambas editan users.ts, cada una termina con un diff limpio en su propia rama —el worktree cumplió, nadie vio los cambios del otro—, pero al llegar el PR una de las dos va a chocar con la otra al integrarse. No es un problema del agente ni del worktree: es el mismo problema que tiene cualquier equipo humano con dos ramas paralelas sobre el mismo archivo. La solución tampoco es distinta: alguien resuelve el conflicto al revisar el segundo PR, o el orquestador serializa las tareas que se sabe que tocan el mismo módulo.

El código de arriba solo limpia el worktree si falla, por brevedad. En producción el flujo completo es crear el worktree, ejecutar el agente, subir la rama, abrir el PR y recién ahí borrar el worktree local con git worktree remove —lo que persiste es la rama, no el worktree—. Dejarlos vivos “por si acaso” es justo lo que llena el repo de carpetas muertas.

Una capa más: el worktree aísla el código, pero no el proceso ni el sistema operativo —el agente sigue corriendo run_command sobre tu máquina—. Un agente serio ejecuta ese worktree dentro de un contenedor efímero —Docker o una microVM tipo Firecracker—, el mismo sandbox del post 3 aplicado al envoltorio entero. Lo dejo fuera aquí para no mezclar dos aislamientos, pero en un repo que importa querrás los dos.

Reproducir el bug antes de arreglarlo

Con el agente aislado, la siguiente pregunta es cómo trabaja una tarea de bug. La tentación es soltarlo a “arreglar el 500 del endpoint de usuarios” y dejar que edite hasta que algo parezca bien, pero sin una señal objetiva parecer bien es todo lo que vas a obtener. La disciplina que convierte eso en un arreglo real es la del write-test-fix, aplicada en orden estricto: primero el test que reproduce el bug, después el arreglo.

El orden importa. Un test que falla por la razón correcta es la prueba de que el agente encontró el bug; si no logra ponerlo en rojo, no lo reprodujo, y cualquier “arreglo” posterior es a ciegas. Solo cuando el test está rojo por el motivo esperado tiene sentido tocar el código, porque ahora hay una condición clara de cuándo el bug dejó de existir: el test pasa a verde.

Bug reportado: "/users devuelve 500 al filtrar por rol"


1. escribe un test que lo reproduzca ──► corre ──► ROJO   ✓ bug confirmado
   │                                       (si sale verde, no reprodujiste el bug)

2. arregla el código ───────────────────► corre ──► VERDE  el test nuevo pasa


3. corre TODA la suite ──────────────────► corre ──► ¿algo viejo en rojo?
                                                    ├── sí ─► es una regresión, vuelve a 2
                                                    └── no ─► terminado

Este test nuevo no es trabajo desechable: queda en el PR como regresión. Es lo que evita que el mismo bug vuelva dentro de tres meses sin que nadie lo note, y es lo que le da a quien revisa una forma de confirmar el arreglo sin leer el diff entero —corre el test, lo ve verde, mira que antes era rojo—. Quién decide qué cuenta como correcto es el evaluador del que hablé en otro post de la serie; aquí ese evaluador es el test de reproducción más la suite que ya existía.

Correr toda la suite, no solo el test nuevo

El paso 3 del diagrama es el que más se omite y el que más cuesta cuando falta. Que el test nuevo pase prueba que el bug se arregló; no prueba que el arreglo no rompió otra cosa. Un cambio en roles.ts para que /users deje de dar 500 puede, sin que nadie lo busque, romper el endpoint de permisos que dependía del mismo arreglo. A eso se le llama regresión, y la única forma de cazarla es correr la suite completa, no solo el test que acabas de escribir.

Por eso la condición de “terminado” del agente no es “mi test pasa”, es “toda la suite pasa y mi test corrió de verdad”. Lo segundo importa: si el gate solo exige que la suite esté verde, el agente puede satisfacerlo borrando el test que se le resistía. Exigir que el test de reproducción aparezca en la salida cierra ese atajo.

// done.ts — "terminado" no es "mi test pasa", es "toda la suite pasa".
import { execSync } from "node:child_process";

export function isDone(worktree: string, newTest: string): boolean {
  try {
    // Corre la suite COMPLETA, no solo el test nuevo: así un arreglo que
    // rompe otra cosa (una regresión) sale rojo aquí.
    const out = execSync("npm test", { cwd: worktree, encoding: "utf8" });
    // Y exige que el test que reprodujo el bug se haya ejecutado de verdad,
    // para que el agente no "apruebe" borrándolo.
    return out.includes(newTest);
  } catch {
    return false; // cualquier test rojo —nuevo o viejo— no es terminado
  }
}
# done.py — "terminado" no es "mi test pasa", es "toda la suite pasa".
import subprocess

def is_done(worktree: str, new_test: str) -> bool:
    try:
        # Corre la suite COMPLETA, no solo el test nuevo: así un arreglo que
        # rompe otra cosa (una regresión) sale rojo aquí.
        out = subprocess.run(["npm", "test"], cwd=worktree, check=True,
                             capture_output=True, text=True).stdout
        # Y exige que el test que reprodujo el bug se haya ejecutado de verdad,
        # para que el agente no "apruebe" borrándolo.
        return new_test in out
    except subprocess.CalledProcessError:
        return False  # cualquier test rojo —nuevo o viejo— no es terminado
<?php
// done.php — "terminado" no es "mi test pasa", es "toda la suite pasa".

function is_done(string $worktree, string $newTest): bool {
    // Corre la suite COMPLETA, no solo el test nuevo: así un arreglo que
    // rompe otra cosa (una regresión) sale rojo aquí.
    $out = [];
    $code = 0;
    exec("cd " . escapeshellarg($worktree) . " && npm test 2>&1", $out, $code);
    if ($code !== 0) {
        return false; // cualquier test rojo —nuevo o viejo— no es terminado
    }
    // Y exige que el test que reprodujo el bug se haya ejecutado de verdad,
    // para que el agente no "apruebe" borrándolo.
    return str_contains(implode("\n", $out), $newTest);
}

Correr la suite entera en cada vuelta tiene un costo: en un repo grande son minutos, y el agente la corre varias veces. La optimización tentadora es correr solo los tests cercanos al cambio, pero eso reabre el hueco que querías cerrar, porque la regresión casi siempre está en el test que no creías relacionado. Un punto medio razonable es dejar que el agente itere contra un subconjunto rápido mientras arregla, y exigir la suite completa solo como gate final. Lo que no funciona es no correrla nunca: sin la suite completa, “terminado” significa “el síntoma desapareció”, no “el cambio es seguro”.

El PR como salida, no el merge

Aquí está la decisión que cambia todo el modelo. Es tentador que, cuando los tests pasan, el agente haga merge a main y cierre la tarea solo. No lo hace. Su salida es un pull request: empaqueta el trabajo en su rama, lo sube y abre un PR. La integración a main la decide una persona.

# Dentro del worktree de la tarea, ya con los tests en verde:
git -C ../wt-fix-users add -A
git -C ../wt-fix-users commit -m "fix: /users devuelve 500 al filtrar por rol"
git -C ../wt-fix-users push -u origin agent/fix-users

# La salida del agente es un PR, no un merge a main:
gh pr create --head agent/fix-users \
  --title "fix: /users devuelve 500 al filtrar por rol" \
  --body "Reproduce el bug con un test nuevo; toda la suite en verde."

El PR no es burocracia, es el punto de control. Un agente puede dejar la suite en verde y aun así haber resuelto el problema equivocado, haber metido un cambio inseguro, o haber “arreglado” el test en lugar del código. Los tests atrapan los errores que sabes anticipar; el PR es donde una persona atrapa los que no. Y como el trabajo vino aislado en su propia rama, ese PR se puede rechazar y borrar sin consecuencias.

Lo que sigue después de abrir el PR no es distinto de lo que ya haces con el trabajo de un humano:

agente ──► PR abierto ──► CI (build + suite) ──► revisión humana ──► merge a main ──► cleanup
                              │                        │
                              └──────── falla ─────────┘
                                (de vuelta al agente, o se cierra el PR)

Lo que más cambia al darle un worktree por tarea no es la velocidad, es que dejas de revisar con miedo: un intento fallido es una rama que se borra, no tu trabajo del día mezclado con el del agente.

Aislamiento y PR son las dos mitades de la misma idea: el agente tiene total libertad para equivocarse dentro de su rama, y cero capacidad de hacer daño fuera de ella. Esa asimetría es lo que hace que valga la pena soltarlo sobre un repo que importa.

Dónde se complica

El patrón es sólido, pero tiene aristas que conviene conocer antes de confiarse:

  • Los worktrees comparten el .git. Operaciones sobre el repositorio común (algunas sobre ramas, el gc) pueden chocar entre worktrees concurrentes. No asumas aislamiento total: la copia de trabajo está aislada, el .git no.
  • Dos tareas paralelas pueden tocar el mismo archivo. El worktree no evita el conflicto, solo lo pospone hasta el segundo PR. Serializa las tareas que sabes que tocan el mismo módulo.
  • Las dependencias se reinstalan por copia. node_modules no se comparte, así que esa instalación repetida —no el git— es el cuello de botella real con muchas tareas a la vez. Cachear el directorio de dependencias es la primera optimización que vas a querer.
  • Los tests inestables envenenan la señal. Si la suite falla de forma intermitente, el gate de “todo verde” se vuelve ruido y el agente reintenta arreglos que no hacían falta. Estabilízala antes de soltar al agente.
  • La limpieza se acumula. Worktrees abandonados y ramas muertas se amontonan si no podas (git worktree prune, borrar ramas integradas o descartadas).
  • Los secretos viajan a la copia. Un .env o credenciales en cada worktree amplían la superficie de exposición; inyecta lo mínimo y nunca lo dejes en un commit de la rama.
  • El PR sigue necesitando una revisión real. El aislamiento evita que el agente rompa main, no que proponga un mal cambio. Si se aprueba sin leer porque “los tests pasan”, perdiste el punto de control.

Ninguna invalida el modelo; lo acotan. El agente es un colaborador que propone trabajo aislado, y la integración sigue siendo una decisión humana.

Preguntas frecuentes

¿Worktree o un clon nuevo por tarea?

Empieza por worktree: aísla la copia de trabajo igual que un clon, pero crearlo es mucho más rápido porque no copia el historial. La única ventaja real del clon es el aislamiento total del .git, que importa si tus tareas hacen operaciones pesadas sobre el repositorio común; para el caso normal —editar, correr tests, commitear en una rama— el worktree basta. En ambos casos las dependencias no se comparten y cada copia paga su instalación, aunque las cachés del gestor la abaratan.

¿Por qué escribir primero el test que falla?

Porque un test que falla por la razón correcta es la única prueba objetiva de que reprodujiste el bug. Escribirlo primero da dos cosas: la confirmación de que entendiste el fallo (sale rojo) y una condición clara de cuándo termina el arreglo (pasa a verde). Además queda como regresión y evita que el bug vuelva sin que nadie lo note.

¿Por qué un PR y no un merge directo a main?

Porque los tests verdes no garantizan que el cambio sea correcto, solo que no rompió lo que sabes verificar. Un agente puede resolver el problema equivocado o meter un cambio inseguro con la suite en verde; el PR es donde una persona revisa lo que los tests no pueden juzgar. Como el trabajo viene aislado en su rama, rechazarlo no cuesta más que borrar una rama.

¿Cómo corro varias tareas en paralelo sin que choquen?

Un worktree y una rama por tarea, identificados por un taskId. Dos tareas editan y corren tests a la vez sin verse, pero “sin verse” no es “sin conflicto”: si tocan el mismo archivo, el choque aparece al abrir el segundo PR, no antes. Y el límite práctico de cuántas corres a la vez tampoco es el git, sino los recursos: cada copia reinstala dependencias y corre su propia suite.

¿Necesito todo esto para un agente pequeño?

No. Para un script que toca un repo de juguete, correr directo es más simple y está bien. Este envoltorio paga cuando el repositorio importa: cuando un cambio mal hecho cuesta tiempo, cuando varias tareas corren a la vez, o cuando quieres dejar al agente trabajar sin vigilarlo. La complejidad se justifica con lo que está en juego.

¿Esto es lo que hacen Claude Code o los agentes de fondo?

Es el esqueleto, sí: entorno aislado por tarea —worktree, contenedor o VM efímera—, rama propia, suite como criterio de terminado, PR como salida. Las diferencias están en la robustez —aislamiento más fuerte, dependencias cacheadas, mejor manejo de tareas largas, permisos más finos—, no en el contrato de fondo.

Conclusión

El salto de este post no está en el loop, que sigue siendo el mismo ReAct con sus herramientas, sino en el envoltorio que lo hace seguro sobre un repo de verdad: aislar cada tarea en su propio worktree y rama, reproducir el bug con un test antes de arreglarlo y exigir la suite completa, y entregar el trabajo como un PR en vez de un merge. Juntas forman una asimetría útil: el agente puede equivocarse todo lo que quiera dentro de su rama, y no puede hacer daño fuera de ella.

Si vas a construirlo, empieza por el worktree por tarea —es la pieza que más tranquilidad da por menos código—, haz que el agente escriba el test de reproducción antes de tocar nada, pon la suite completa como gate final, y que la última acción del loop sea abrir un PR. Con eso tienes un agente que opera sobre un repositorio real sin que tengas que mirarlo.

Seguir leyendo