Lesson 09 · 24 in the route · 5 min read · Level 2
First distributed task
enjambre run: Windows sends, a branch and a ledger entry come back.
The story behind this lesson The first distributed task: Windows sends, the Mac works, git returns
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.
Goal
From Windows, send a TASK-<id>.md to the Mac with a single command and get back a git branch
with the result and a line in the ledger.
Why
This is the project’s walking skeleton: the thinnest, ugliest version that works end to end. It teaches more than a pretty dispatcher with nothing behind it, because every piece (SSH, worktree, tmux, push, ledger) is already proven in its real place.
The isolation decisions, with their reasons:
- A worktree per task. Each job has its own copy of the files; two jobs (or a job and you)
never write the same file at the same time. They share the
.git, so the branch exists in the repo without copying anything. - A branch per task (
mac/<id>). The result is reviewable and disposable. Merging tomainis a human decision, never the agent’s. - Git is the bus, not a shared folder. Two processes writing the same network folder is a race you do not see until a file ends up half written. With git, the result arrives complete or it does not arrive.
- Explicit
--allowedTools, never--dangerously-skip-permissions. Nobody watches the Mac while it works: whatever is not on the list is denied, not asked about. - tmux. If SSH drops, the job keeps going;
tmux wait-formakes the Windows command wait for it to finish. - The contract. An unattended agent cannot ask you what you meant, so the task must say which
files it may touch (
## Files you may touch) and when it is done (## Done criteria). Without that,enjambrerejects it before connecting.
Steps
The round trip:
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
Commands and real output (2026-10-04), against a private test repo (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
A real ledger line (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}
And on GitHub, the mac/changelog-stub branch carries the change and its result.json.
What a stub is: a fake worker that does something trivial and predictable (add a line, or
fail on purpose). It lets you test the pipeline without spending a model or depending on its
login. The failure path (stub-fail) proves criterion E4-1: even if the task fails, the branch is
published with a result.json explaining the error and the command exits with a code ≠ 0.
What actually went wrong
- The Mac cannot push to GitHub over SSH. The
ghcredentials are in the macOS keychain, which an SSH session cannot open. Fix without moving tokens: Windows fetches the branch from the Mac withgit fetch mac:Documents/Portfolio/<repo> mac/<id>:mac/<id>, scans it with gitleaks and pushes it itself. Git is still the bus; only who makes the last hop changes. - The real
claudeworker ended with"Not logged in · Please run /login"for the same reason (the Claude Code login also lives in the keychain). The pipeline worked in full (branch, ledger, push) but the model never got to work; it is the third line ofstatus, withexit=1. Pending for the owner (NEEDS-OWNER.md§1; see09-agente-desatendido.md). result.jsoncarried the machine’s real name (hostname). It now uses the node name (mac) that the orchestrator passes in: data that travels must not identify the machine. Two test branches already published keep the old value (see09-agente-desatendido.md).
How to verify
Without the Mac, the tests of the Windows piece:
PS> python -m unittest discover -s tests -v → 12 tests OK
They cover the contract (file name, sections, AGENTS.md), the rejection rule for restricted
repos with external workers (including that run exits with 3 without touching the network)
and that the ledger is append-only, with LF line endings on Windows too.
With the Mac: python enjambre.py status must show a line per task, and the mac/<id> branch
must exist on GitHub.
Self-check
- Why a worktree per task and not a single working folder?
Answer
So that two jobs (or you and a job) never write the same file at the same time; they share the `.git` but not the files. - What does the
stub-failworker prove?Answer
That when the task fails, the branch is still published with a `result.json` that explains the error, and the command exits with a nonzero code. - The Mac cannot push. How does the branch reach GitHub without copying a token?
Answer
Windows runs `git fetch` for the branch from the Mac over SSH, scans it with gitleaks and pushes it itself.