Lección 09 · 24 en la ruta · 5 min de lectura · Nivel 2
Primera tarea distribuida
enjambre run: Windows manda, vuelve una rama + ledger
La historia detrás de esta lección La primera tarea distribuida: Windows manda, la Mac hace, git devuelve
Con la Mac lista, quedaba la pregunta que motivó todo: ¿puedo mandar una tarea desde Windows y recibir un resultado revisable? Construí la versión más delgada que funciona de punta a punta, lo que en historias de usuario se llama un walking skeleton. Los comandos y la salida completa están en el paso primera tarea distribuida.
Por qué un esqueleto feo
Un despachador bonito sin nada detrás enseña poco. Uno feo pero completo obliga a que cada pieza (SSH, worktree, tmux, push, registro) funcione en su lugar real desde el primer día. Esa es la razón de hacerlo así.
El viaje
Desde Windows corro enjambre.py run --node mac --repo <repo> --task TASK-x.md. Lo que ocurre:
- Windows valida el contrato de la tarea y una regla de exclusión de repos. Si falla, sale con código 3 y no toca la red.
- Copia
worker.sha la Mac y lo ejecuta por SSH, pasándole la tarea por la entrada estándar. - En la Mac se crea un worktree nuevo con una rama
mac/<id>. - Dentro de tmux corre el worker.
- El worker deja un
result.jsony un commit en la rama. - Se intenta el
git push. Si la Mac no puede, Windows trae la rama por SSH, la escanea con gitleaks y la sube él. - Se añade una línea a
jobs.jsonl, el registro.
Las decisiones de aislamiento
- Un worktree por tarea. Cada trabajo tiene sus propios archivos; dos trabajos, o un trabajo y yo, nunca escribimos el mismo archivo a la vez. Comparten el
.git. - Una rama por tarea. El resultado es revisable y desechable. Fusionar a
maines decisión mía, no del agente. - Git es el bus, no una carpeta compartida. Dos procesos escribiendo una carpeta de red es una carrera que no ves hasta que un archivo queda a medias. Con git, el resultado llega completo o no llega.
--allowedToolsexplícito, nunca--dangerously-skip-permissionsen el worker. Nadie mira la Mac mientras trabaja: lo que no está en la lista se niega, no se pregunta.- tmux. Si se cae el SSH, el trabajo sigue.
- Un contrato por tarea. Un agente desatendido no puede preguntar qué quise decir. La tarea debe indicar qué archivos puede tocar y cuándo está terminada. Sin eso, el orquestador la rechaza antes de conectarse.
Qué es un stub
Para probar la tubería sin gastar un modelo ni depender de su login, usé stubs: workers falsos que hacen algo trivial y predecible. stub-ok añade una línea; stub-fail falla a propósito. El de fallo prueba que, aunque la tarea falle, la rama se publica con un result.json que explica el error y el comando sale con código distinto de cero.
El registro mostró tres trabajos:
changelog-stub mac stub-ok exit=0 pushed=True mac/changelog-stub
must-fail mac stub-fail exit=1 pushed=True mac/must-fail
claude-changelog mac claude exit=1 pushed=True mac/claude-changelog
El tercero es el worker real, y terminó con exit=1. La causa no fue la tubería sino el login de Claude Code en la Mac, que no se puede abrir por SSH. Lo cuento con detalle en Una noche con un agente desatendido. Por eso no cuento ese trabajo como prueba de que el modelo trabajó: lo que probó es que rama, registro y push funcionan aun cuando el modelo no llega a ejecutarse.
Tres tropiezos
- La Mac no puede pushear por SSH. La rama la trae Windows, la escanea y la sube. Cambió quién da el último salto, no el diseño.
result.jsonllevaba el nombre real del equipo. Ahora usa el nombre del nodo (mac): los datos que viajan no deben identificar la máquina.- El worker real dijo “Not logged in”.
Pruebas locales
El orquestador tiene 12 pruebas que corren sin Mac. Cubren el contrato de la tarea, la regla de rechazo (incluido que run sale con 3 sin tocar la red) y que el registro es solo-agregar, con saltos de línea LF también en Windows.
Lo que falta
Esta serie sigue con el respaldo verificado de los repos y con VS Code Remote-SSH. Ninguno está ejecutado todavía; los pasos de Remote-SSH están escritos pero requieren la interfaz gráfica.
Objetivo
Desde Windows, mandar un TASK-<id>.md a la Mac con un solo comando y recibir de vuelta una
rama de git con el resultado y una línea en el registro (ledger).
Por qué
Es el walking skeleton del proyecto: la versión más delgada y fea que funciona de punta a punta. Enseña más que un despachador bonito sin nada detrás, porque cada pieza (SSH, worktree, tmux, push, ledger) ya está probada en su lugar real.
Las decisiones de aislamiento, con su porqué:
- Un worktree por tarea. Cada trabajo tiene su propia copia de los archivos; dos trabajos (o
un trabajo y tú) nunca escriben el mismo archivo a la vez. Comparten el
.git, así que la rama existe en el repo sin copiar nada. - Una rama por tarea (
mac/<id>). El resultado es revisable y desechable. Fusionar amaines una decisión humana, nunca del agente. - Git es el bus, no una carpeta compartida. Dos procesos escribiendo la misma carpeta de red es una carrera que no ves hasta que un archivo queda a medias. Con git, el resultado llega completo o no llega.
--allowedToolsexplícito, nunca--dangerously-skip-permissions. Nadie mira la Mac mientras trabaja: lo que no está en la lista se niega, no se pregunta.- tmux. Si se cae el SSH, el trabajo sigue;
tmux wait-forhace que el comando de Windows espere a que termine. - El contrato. Un agente desatendido no puede preguntarte qué quisiste decir, así que la
tarea debe decir qué archivos puede tocar (
## Files you may touch) y cuándo está terminada (## Done criteria). Sin eso,enjambrela rechaza antes de conectarse.
Pasos
El viaje de ida y vuelta:
WINDOWS (orquesta) MAC (ejecuta)
────────────────── ─────────────
enjambre.py run --node mac --task TASK-x.md
1. valida el contrato (TASK + AGENTS.md)
y la regla de repos restringidos ── si falla: exit 3,
no se toca la red
2. scp worker.sh ───────────────────────────────▶ ~/.enjambre/worker.sh
3. ssh mac bash worker.sh < TASK-x.md ───────────▶ 4. git worktree add ../wt/<repo>-x -b mac/x
5. tmux: worker (claude -p … | stub)
6. result.json + commit en mac/x
7. git push (si hay credenciales)
8. si la Mac no pudo pushear: ◀── git fetch por ssh ── rama mac/x
gitleaks sobre la rama → git push a GitHub
9. una línea en jobs.jsonl
Comandos y salida real (2026-10-04), contra un repo privado de pruebas (enjambre-sandbox):
PS> python enjambre.py run --node mac --repo enjambre-sandbox --task tasks/TASK-changelog-stub.md --worker stub-ok
PS> python enjambre.py run --node mac --repo enjambre-sandbox --task tasks/TASK-must-fail.md --worker stub-fail
PS> python enjambre.py status
2026-10-04T11:54:02+00:00 changelog-stub mac stub-ok exit=0 pushed=True mac/changelog-stub
2026-10-04T11:54:15+00:00 must-fail mac stub-fail exit=1 pushed=True mac/must-fail
2026-10-04T11:54:33+00:00 claude-changelog mac claude exit=1 pushed=True mac/claude-changelog
Línea real del ledger (jobs.jsonl):
{"id": "changelog-stub", "node": "mac", "worker": "stub-ok", "repo": "enjambre-sandbox",
"branch": "mac/changelog-stub", "start": "2026-10-04T11:54:02+00:00",
"end": "2026-10-04T11:54:08+00:00", "exit": 0, "duration_s": 0, "pushed": true,
"usage": {}, "cost_usd": null}
Y en GitHub, la rama mac/changelog-stub trae el cambio y su result.json.
Qué es un stub: un worker falso que hace algo trivial y predecible (añadir una línea, o
fallar a propósito). Sirve para probar la tubería sin gastar un modelo ni depender de su
login. El camino de fallo (stub-fail) prueba el criterio E4-1: aunque la tarea falle, la rama
se publica con result.json explicando el error y el comando sale con código ≠ 0.
Qué salió mal de verdad
- La Mac no puede pushear a GitHub por SSH. Las credenciales de
ghestán en el llavero de macOS, que una sesión SSH no puede abrir. Solución sin mover tokens: Windows trae la rama desde la Mac congit fetch mac:Documents/Portfolio/<repo> mac/<id>:mac/<id>, la escanea con gitleaks y la pushea él. Git sigue siendo el bus; solo cambia quién hace el último salto. - El worker
claudereal terminó en"Not logged in · Please run /login"por la misma razón (el login de Claude Code también vive en el llavero). La tubería funcionó entera (rama, ledger, push) pero el modelo no llegó a trabajar; es la tercera línea destatus, conexit=1. Pendiente del owner (NEEDS-OWNER.md§1; ver09-agente-desatendido.md). result.jsonllevaba el nombre real del equipo (hostname). Ahora usa el nombre del nodo (mac) que le pasa el orquestador: los datos que viajan no deben identificar la máquina. Dos ramas de prueba ya publicadas conservan el valor anterior (ver09-agente-desatendido.md).
Cómo verificar
Sin la Mac, las pruebas de la pieza de Windows:
PS> python -m unittest discover -s tests -v → 12 tests OK
Cubren el contrato (nombre del archivo, secciones, AGENTS.md), la regla de rechazo de repos restringidos
para workers externos (incluido que run sale con 3 sin tocar la red) y que el ledger es
solo-agregar, con saltos de línea LF también en Windows.
Con la Mac: python enjambre.py status debe mostrar una línea por tarea, y la rama mac/<id>
debe existir en GitHub.
Autoevaluación
- ¿Por qué un worktree por tarea y no una sola carpeta de trabajo?
Respuesta
Para que dos trabajos (o tú y un trabajo) nunca escriban el mismo archivo a la vez; comparten el `.git` pero no los archivos. - ¿Qué prueba el worker
stub-fail?Respuesta
Que cuando la tarea falla, la rama se publica igual con un `result.json` que explica el error, y el comando sale con código distinto de cero. - La Mac no puede pushear. ¿Cómo llega la rama a GitHub sin copiar un token?
Respuesta
Windows hace `git fetch` de la rama desde la Mac por SSH, la escanea con gitleaks y la pushea él.