Skip to content

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:

  1. 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.
  2. Copia worker.sh a la Mac y lo ejecuta por SSH, pasándole la tarea por la entrada estándar.
  3. En la Mac se crea un worktree nuevo con una rama mac/<id>.
  4. Dentro de tmux corre el worker.
  5. El worker deja un result.json y un commit en la rama.
  6. Se intenta el git push. Si la Mac no puede, Windows trae la rama por SSH, la escanea con gitleaks y la sube él.
  7. 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 main es 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.
  • --allowedTools explícito, nunca --dangerously-skip-permissions en 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.json llevaba 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 to main is 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-for makes 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, enjambre rejects 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 gh credentials are in the macOS keychain, which an SSH session cannot open. Fix without moving tokens: Windows fetches the branch from the Mac with git 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 claude worker 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 of status, with exit=1. Pending for the owner (NEEDS-OWNER.md §1; see 09-agente-desatendido.md).
  • result.json carried 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 (see 09-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

  1. Why a worktree per task and not a single working folder?
    AnswerSo 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.
  2. What does the stub-fail worker prove?
    AnswerThat 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.
  3. The Mac cannot push. How does the branch reach GitHub without copying a token?
    AnswerWindows runs `git fetch` for the branch from the Mac over SSH, scans it with gitleaks and pushes it itself.
Saved only in this browser.