Cuando un agente de IA edita archivos de tu proyecto y rompe algo, el reflejo es git checkout -- . o git stash. Los dos responden a una pregunta distinta de la que tienes. Git puede llevarte al último commit. Lo que quieres es la carpeta tal como estaba justo antes del turno del agente, con tu propio trabajo sin commitear y los archivos que git ignora, y sin los archivos que creó el agente.

La aplicación de escritorio de osFoundry lo resuelve con un pequeño almacén direccionado por contenido escrito en Rust, checkpoint_store.rs. Antes de que una escritura del agente llegue a un archivo, los bytes actuales del archivo se guardan en <project>/.osfoundry/checkpoints/blobs/<sha256>, y /rewind los vuelve a escribir. Aquí tienes el mismo diseño en unas 90 líneas de Python con solo la biblioteca estándar, comparado con cuatro recetas de git sobre un turno realista y cronometrado sobre una copia de un árbol de código TypeScript real de 4,686 archivos.

Un turno, cinco formas de deshacerlo

El proyecto de prueba es un sitio diminuto en un repositorio git: index.html, styles.css (212 líneas), app.js y un .env que .gitignore excluye. Después del último commit, el usuario cambió el título de la página en index.html y empezó un notes.md sin seguimiento. Ese es el estado al que hay que volver.

Luego viene el turno del agente: reescribe styles.css hasta dejarlo en 41 líneas, edita index.html para cargar un nuevo theme.css, reescribe app.js, apunta API_URL en .env a localhost y crea theme.css. Cada método de deshacer se ejecutó sobre una copia limpia, y un script comparó cada archivo byte a byte con el estado anterior al turno.

checkpoint restore matches the state before the turn: True differs: [] git checkout -- . matches the state before the turn: False differs: ['.env', 'index.html', 'theme.css'] git stash -u matches the state before the turn: False differs: ['.env', 'index.html', 'notes.md'] git checkout + git clean -fd matches the state before the turn: False differs: ['.env', 'index.html', 'notes.md'] git stash create before the turn matches the state before the turn: False differs: ['.env', 'theme.css'] 
Método Qué sale mal
git checkout -- . La edición del título del usuario se pierde junto con las del agente, theme.css se queda y .env conserva el valor del agente
git stash -u Los cambios del agente, la edición del título del usuario y notes.md acaban juntos en un mismo stash, y .env conserva el valor del agente
git checkout + git clean -fd theme.css se elimina, pero notes.md también, la edición del título desaparece y .env conserva el valor del agente
git stash create antes del turno Todo lo que tiene seguimiento vuelve, pero theme.css se queda y .env nunca entra en la instantánea
restauración del checkpoint Coincide con el estado anterior al turno

Los resultados de git se deducen de lo que es git. Restaura el último commit, o una instantánea de los archivos con seguimiento y en staging, y ambas cosas mezclan tu trabajo con el del agente en cuanto ninguno de los dos está commiteado. Los archivos ignorados le resultan invisibles salvo que añadas -a, y un checkout nunca borra un archivo que no existía en la instantánea. La última fila es la configuración de git más justa, una instantánea tomada antes de cada turno, y aun así falla en dos de los cinco archivos. Además tiene un efecto secundario: git add -A pone en staging todo tu trabajo en el índice real en cada turno.

El almacén

Un checkpoint es una lista por turno: para cada archivo que escribió el agente, o bien el hash de su contenido anterior, o bien una nota de que no existía. Los bytes van a blobs/ una sola vez por cada contenido distinto.

import hashlib, json, os, time from pathlib import Path MAX_BLOB = 8 * 1024 * 1024 class Checkpoints: def __init__(self, root): self.root = Path(root).resolve() self.dir = self.root / ".osfoundry" / "checkpoints" self.blobs = self.dir / "blobs" self.index_file = self.dir / "index.json" try: self.index = json.loads(self.index_file.read_text()) except (OSError, ValueError): self.index = [] def _save(self): self.dir.mkdir(parents=True, exist_ok=True) tmp = self.dir / "index.json.tmp" tmp.write_text(json.dumps(self.index)) os.replace(tmp, self.index_file) # a half-written index loses everything  def capture(self, turn, label, path): path = str(Path(path).resolve()) if Path(path).exists() else str(Path(path).absolute()) if not self._contained(path): raise ValueError("outside the workspace: " + path) cp = next((c for c in self.index if c["turn"] == turn), None) if cp and any(f["path"] == path for f in cp["files"]): return # first write in a turn wins  try: size = os.path.getsize(path) except FileNotFoundError: pre = {"kind": "absent"} else: if size > MAX_BLOB: pre = {"kind": "skipped", "reason": f"{size >> 20} MB over the limit"} else: data = Path(path).read_bytes() sha = hashlib.sha256(data).hexdigest() blob = self.blobs / sha if not blob.exists(): # identical bytes are stored once  self.blobs.mkdir(parents=True, exist_ok=True) blob.write_bytes(data) pre = {"kind": "content", "sha256": sha, "bytes": size} entry = {"path": path, "pre": pre} if cp: cp["files"].append(entry) else: self.index.append({"turn": turn, "label": label[:120], "at": time.time_ns(), "files": [entry]}) self._save() def write(self, turn, label, path, data): """What an agent's write tool would call.""" self.capture(turn, label, path) Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).write_bytes(data) 

Tres detalles deciden si una restauración es correcta, y cada uno es también una regla en el módulo de Rust.

Gana la primera captura de una ruta en un turno. Un agente escribe a menudo el mismo archivo varias veces en un turno. Rebobinar significa volver a cómo estaba el archivo cuando empezó el turno, no a algún estado entre dos escrituras del propio agente, así que las capturas posteriores de la misma ruta en el mismo turno se ignoran.

Un archivo que no existe se registra como absent. Esa única línea es lo que permite que una restauración borre theme.css. Git no tiene equivalente para "este archivo no debe existir", salvo un git clean que también borra archivos que querías conservar.

Los archivos de más de 8 MB se registran como skipped con un motivo, no se omiten. Así, una restauración informa de que no pudo devolverlos, en lugar de terminar en silencio con una mezcla de archivos viejos y nuevos.

Restaurar, del más antiguo al más reciente

 def restore(self, turn): target = next(c["at"] for c in self.index if c["turn"] == turn) affected = sorted((c for c in self.index if c["at"] >= target), key=lambda c: c["at"]) # oldest first  done, report = set(), {"restored": [], "deleted": [], "failed": []} for cp in affected: for f in cp["files"]: path, pre = f["path"], f["pre"] if path in done: continue done.add(path) if not self._contained(path): # re-check, the index is just a file  report["failed"].append((path, "outside the workspace")) elif pre["kind"] == "skipped": report["failed"].append((path, pre["reason"])) elif pre["kind"] == "absent": Path(path).unlink(missing_ok=True) report["deleted"].append(path) else: Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).write_bytes((self.blobs / pre["sha256"]).read_bytes()) report["restored"].append(path) self.index = [c for c in self.index if c["at"] < target] self._save() return report def _contained(self, path): p = Path(path) if ".." in p.parts: return False p = p if p.is_absolute() else self.root / p while not p.exists(): # deepest existing ancestor, symlinks resolved  p = p.parent return p.resolve().is_relative_to(self.root) 

Rebobinar el turno 1 cuando después vinieron los turnos 2 y 3 debe deshacer los tres. Restaurar solo el turno 1 dejaría archivos del turno 2 junto a archivos de antes del turno 1, una combinación que nunca existió. El orden también importa. Para un archivo tocado en varios turnos, los bytes correctos son la preimagen más antigua, la que se capturó cuando empezó el turno 1. Recorrer del más reciente al más antiguo acaba en la preimagen del turno 2, que es el estado que produjo el turno 1. El código Rust tiene un comentario justo sobre esto: al principio se escribió del más reciente al más antiguo, y el test rewinding_a_turn_also_undoes_later_turns lo detectó.

La comprobación de la ruta se repite al restaurar porque index.json es un archivo normal dentro de la carpeta que describe. Si se confiara en él, cualquiera o cualquier cosa capaz de editarlo podría hacer que una restauración escribiera en cualquier parte del disco. Después, los turnos rebobinados se eliminan del índice, para que un segundo rebobinado al mismo punto no pueda "tener éxito" sobre archivos que ya están de vuelta.

Cuánto cuesta en una carpeta real

El árbol es una copia de la carpeta src de una app real de React y TypeScript: 4,686 archivos, 121.4 MB, ninguno de más de 8 MB. En cada turno se eligieron archivos al azar, se capturaron y se les añadió una línea; luego se ejecutó una restauración y se comparó cada archivo con el original. Todas las restauraciones coincidieron. Apple M1, 8 GB, macOS 15, SSD con APFS, Python 3.9.6, git 2.39.5.

Turno Captura + escritura Tamaño del almacén Restauración
4 archivos + 1 creado 3.5 ms menos de 0.1 MB 1.2 ms
50 archivos + 1 creado 62.4 ms 0.9 MB 8.2 ms
500 archivos + 1 creado 1,035 ms 11.7 MB 227 ms
los 4,686 archivos 30.5 s 122.7 MB 1.3 s

Para comparar, la instantánea de git por turno del escenario anterior (git add -A y luego git stash create) tardó entre 61 y 68 ms en este árbol sin ningún cambio, y entre 50 y 67 ms con cuatro archivos editados. Tiene que mirar los 4,686 archivos cada vez. Justo después de la ejecución que reescribió todos los archivos, la siguiente tardó 1,241 ms, porque git vuelve a calcular el hash de cada archivo cuya marca de tiempo cambió. Un checkpoint solo mira los archivos que se están escribiendo, así que un turno normal cuesta unos pocos milisegundos sea cual sea el tamaño del proyecto.

La última fila deja ver un problema en el código tal como está. La captura media tardó 1.29 ms en los primeros 100 archivos del turno y 9.54 ms en los últimos 100. Cada captura reescribe el index.json entero, que llegó a 1.3 MB, así que el coste de cada escritura crece con el número de escrituras anteriores. Sustituir _save por un append de una línea JSON por captura lo arregló con los mismos 4,686 archivos: 2.5 s en total, 0.41 ms por captura al principio y 0.61 ms al final. Para un agente que edita unos pocos archivos por turno, la versión simple basta. Para un formateador o un renombrado en todo el código ejecutado a través de un agente, usa el log de solo append.

Lo que ahorra el direccionamiento por contenido, y lo que no

Nombrar los blobs por su SHA-256 deduplica los bytes idénticos y nada más. Tres mediciones muestran dónde queda eso.

En todo el árbol, 4,686 archivos tenían 4,677 contenidos distintos, así que un turno sobre el árbol completo guardó 4,677 blobs. Los archivos duplicados son raros en un árbol de código real.

Un archivo de 2 MB reescrito en diez turnos, con el agente añadiendo una línea cada vez: diez preimágenes, todas distintas, 10 blobs y 20.0 MB. El mismo archivo, pero el agente escribe los mismos bytes en cada turno, como hace un reintento que produce una salida idéntica: 2 blobs y 4.0 MB, el original y la versión desde la que partió cada turno posterior. En los dos casos, rebobinar al primer turno devolvió el original byte a byte.

Los blobs se guardan sin comprimir. Los 121.4 MB de blobs del turno sobre el árbol completo serían 33.7 MB con zlib a nivel 6, y el propio almacén de objetos de git para el mismo árbol tras un commit ocupaba 41.0 MB. La compresión es un añadido razonable si el almacén se conserva mucho tiempo, ya que el código fuente se comprime aproximadamente 3.6 a 1. Los deltas entre versiones, como en los pack files de git, ahorrarían más en el caso de añadir una línea, pero cuestan mucho más código del que necesita un almacén de preimágenes.

Límites de esta versión

Solo ve las escrituras que pasan por write(). Si tu agente también puede ejecutar comandos de shell, un rm o un sed -i se le escapan por completo. La aplicación de osFoundry hace pasar las escrituras de archivos del agente por una sola función, que es donde captura.

Una restauración escribe la preimagen sin importar lo que haya ahora en el archivo. Si editaste un archivo a mano después del agente, rebobinar ese turno también reemplaza tu edición. Una versión más estricta registra el hash de lo que escribió el agente y, antes de restaurar, comprueba si el archivo sigue teniendo ese hash. Si no, guarda primero los bytes actuales como un blob más, de modo que también se pueda deshacer la restauración.

Además, el almacén crece hasta que se rebobinan turnos. Basta con un prune que borre los blobs a los que no hace referencia ningún checkpoint, más un límite de cuántos turnos conservar.

Los dos bloques de la clase de arriba son el almacén completo, Python 3.9 con la biblioteca estándar y nada que instalar. La aplicación de escritorio de osFoundry incluye la versión en Rust como /rewind en los chats que trabajan en una carpeta local, gratis desde la página de descarga de osFoundry.