Cuerpo del post

Cómo evito que dos agentes Claude Code editen el mismo archivo a la vez

Antes de empezar: este email llega tarde. Salía martes a las 9:00 fijo y son más de la una. He preferido mandarlo igualmente a esperar al martes que viene, porque lo de esta semana es justo lo que me ha hecho llegar tarde.

Vamos.

La semana pasada llegó el primer agente del equipo de agentdeck: View Builder. Esta semana llegó el segundo: Doc Writer. Y cinco minutos después de tener dos agentes con permiso para escribir archivos, llegó la pregunta obvia que llevaba semanas evitando: ¿qué pasa si los dos quieren tocar el mismo archivo a la vez?

La respuesta, sin orquestación, es: cosas malas en silencio. Uno sobrescribe al otro, se pierde un cambio, y lo descubres días después cuando el README no dice lo que esperabas.

El caso real con el que me topé

El View Builder construye una pantalla nueva y, dentro de su trabajo, actualiza el README para mencionarla. El Doc Writer está actualizando el CHANGELOG con el resumen de la semana, y como parte de su flujo también necesita leer y a veces tocar el README. Si los lanzo en paralelo (cosa que pasa de manera natural cuando delegas tareas en Claude Code), ambos pueden acabar haciendo Write sobre README.md con segundos de diferencia.

Sin coordinación, lo que pasa es lo previsible: gana el último escritor y el primero pierde su cambio sin enterarse nadie. Bug silencioso de manual.

La solución: hooks nativos de Claude Code

Hace meses, cuando Anthropic lanzó los hooks en Claude Code, pensé "esto algún día me va a hacer falta". Algún día es esta semana.

Un hook es un script que Claude Code dispara automáticamente en eventos del ciclo de vida: antes de usar una herramienta, después de usarla, cuando termina una sesión, etc. Si el hook devuelve un código de salida distinto de cero en el evento PreToolUse, Claude Code bloquea la operación. Eso es todo lo que necesito.

Lo que monté es un sistema de lock por archivo en cuatro puntos:

  • Antes de cualquier Edit/Write/MultiEdit: el hook mira si otra sesión ya tiene el lock de ese archivo. Si está libre, lo adquiere y deja pasar. Si está ocupado, devuelve exit 1 con un mensaje útil y Claude Code rechaza la edición.

  • Después de cada Edit/Write/MultiEdit: libera el lock.

  • Al terminar una sesión (Stop) o un subagente (SubagentStop): limpia todos los locks de esa sesión, por si murió sin liberar.

El estado vive en un JSON local (.claude/state/locks.json, gitignored). El lock tiene TTL de 5 minutos: si una sesión muere sin liberar y los hooks de cleanup no se disparan por lo que sea, el lock caduca solo y no bloquea para siempre.

Lo configuras en .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/file-lock.sh acquire" }
        ]
      }
    ]
  }
}

Y los hooks PostToolUse, Stop y SubagentStop apuntan al mismo script con los modos release y release-session.

La decisión técnica que más me hizo pensar

El hook se ejecuta en cada Edit/Write/MultiEdit que cualquier agente intente hacer. Si un agente está construyendo una pantalla, eso son fácilmente 20-30 escrituras. Multiplicado por los días que llevo desarrollando, son miles de invocaciones del hook.

Mi primer instinto fue escribirlo en Node — total, el resto del proyecto es TypeScript. Pero hice un cálculo rápido: arrancar un proceso Node desde cero, leer package.json aunque no haga falta, cargar el runtime, son entre 200 y 500 ms por invocación. Multiplicado por miles de invocaciones, son horas de mi tiempo de desarrollo gastadas en latencia que no aporta nada.

Acabé escribiéndolo en Bash con jq. Arranque <50 ms. Diez veces más rápido que Node para algo que vive en el critical path de cada edición.

La regla que me llevo: cuando un script se ejecuta cientos o miles de veces al día, el lenguaje del proyecto no es necesariamente el lenguaje correcto para ese script. Bash existe por algo. Cuando vivas en el critical path y no necesites lógica compleja, úsalo.

Verificación honesta

Una cosa es decir "tengo un lock". Otra es demostrar que funciona en concurrencia real. Lancé dos hooks acquire en paralelo, con el mismo file_path y session_id distintos, y comprobé que exactamente uno pasa y exactamente uno se bloquea. Dos corridas seguidas, mismo resultado. Sin race conditions detectables en 100 milisegundos de margen.

No es un sistema de concurrencia distribuido — es un lock local de un proceso de Claude Code en una sola máquina. Para multi-usuario el estado tendría que vivir en Postgres o Redis. Pero para mi caso (un dev, una máquina, varios agentes que se pueden lanzar concurrentes vía la Task tool), basta y sobra.

Lo que llevo a final de semana 4

  • Dos agentes del equipo operativos: View Builder y Doc Writer

  • Sistema de file-locking nativo con hooks de Claude Code

  • Verificado con prueba de concurrencia real, no en abstracto

  • Una decisión técnica concreta sobre cuándo NO usar el lenguaje del proyecto

El equipo de seis agentes sigue avanzando: dos arriba, cuatro por venir. La semana que viene toca pricing — un giro respecto a las semanas técnicas, pero igual de necesario.

Próxima semana

Semana 5 — decisión final de pricing: límites del free tier, features que entran en Pro, y por qué este es el primer momento donde los datos del producto empiezan a importar más que las opiniones.

— Sergio

Sigue leyendo