Quando un agente AI modifica i file del tuo progetto e rompe qualcosa, il riflesso è git checkout -- . oppure git stash. Entrambi rispondono a una domanda diversa da quella che hai. Git può riportarti all'ultimo commit. Quello che vuoi è la cartella com'era subito prima del turno dell'agente, con il tuo lavoro non ancora committato e i file che git ignora, e senza i file creati dall'agente.

L'app desktop di osFoundry risolve il problema con un piccolo store indirizzato per contenuto scritto in Rust, checkpoint_store.rs. Prima che una scrittura dell'agente arrivi a un file, i byte attuali del file vengono salvati in <project>/.osfoundry/checkpoints/blobs/<sha256>, e /rewind li riscrive. Qui sotto c'è lo stesso design in circa 90 righe di Python con la sola libreria standard, messo a confronto con quattro ricette git su un turno realistico e cronometrato su una copia di un vero albero di sorgenti TypeScript da 4,686 file.

Un turno, cinque modi per annullarlo

Il progetto di test è un sito minuscolo in un repository git: index.html, styles.css (212 righe), app.js e un .env escluso da .gitignore. Dopo l'ultimo commit, l'utente ha cambiato il titolo della pagina in index.html e ha iniziato un notes.md non tracciato. È questo lo stato a cui bisogna tornare.

Poi arriva il turno dell'agente: riscrive styles.css riducendolo a 41 righe, modifica index.html per caricare un nuovo theme.css, riscrive app.js, fa puntare API_URL in .env a localhost e crea theme.css. Ogni metodo di annullamento è stato eseguito su una copia pulita, e uno script ha confrontato ogni file byte per byte con lo stato precedente 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'] 
Metodo Cosa va storto
git checkout -- . La modifica al titolo dell'utente si perde insieme a quelle dell'agente, theme.css resta, .env mantiene il valore dell'agente
git stash -u Le modifiche dell'agente, la modifica al titolo dell'utente e notes.md finiscono tutte insieme in un unico stash, e .env mantiene il valore dell'agente
git checkout + git clean -fd theme.css viene rimosso, ma anche notes.md, la modifica al titolo sparisce e .env mantiene il valore dell'agente
git stash create prima del turno Tutto ciò che è tracciato torna indietro, ma theme.css resta e .env non entra mai nello snapshot
ripristino del checkpoint Corrisponde allo stato precedente al turno

I risultati di git derivano da quello che git è. Ripristina l'ultimo commit, oppure uno snapshot dei file tracciati e in staging, ed entrambe le cose mescolano il tuo lavoro con quello dell'agente appena nessuno dei due è committato. I file ignorati per git sono invisibili a meno che tu non aggiunga -a, e un checkout non cancella mai un file che non esisteva nello snapshot. L'ultima riga è la configurazione git più onesta, uno snapshot preso prima di ogni turno, e sbaglia comunque due file su cinque. Ha anche un effetto collaterale: git add -A mette in staging tutto il tuo lavoro nell'indice vero a ogni turno.

Lo store

Un checkpoint è una lista per turno: per ogni file scritto dall'agente, c'è o l'hash del suo contenuto precedente o una nota che dice che non esisteva. I byte finiscono in blobs/ una sola volta per ogni contenuto 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) 

Tre dettagli decidono se un ripristino è corretto, e ognuno è una regola anche nel modulo Rust.

Vince la prima cattura di un percorso in un turno. Un agente scrive spesso lo stesso file più volte nello stesso turno. Tornare indietro significa tornare a com'era il file all'inizio del turno, non a uno stato intermedio tra due scritture dell'agente stesso, quindi le catture successive dello stesso percorso nello stesso turno vengono ignorate.

Un file mancante viene registrato come absent. È proprio questa riga a permettere a un ripristino di cancellare theme.css. Git non ha un equivalente di "questo file non deve esistere", a parte un git clean che cancella anche i file che volevi tenere.

I file sopra gli 8 MB vengono registrati come skipped con un motivo, invece di essere saltati in silenzio. Un ripristino segnala quindi che non è riuscito a rimetterli a posto, invece di finire senza dire nulla con un misto di file vecchi e nuovi.

Ripristinare, dal più vecchio al più recente

 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) 

Tornare al turno 1 quando dopo sono venuti i turni 2 e 3 deve annullarli tutti e tre. Ripristinare solo il turno 1 lascerebbe file del turno 2 accanto a file di prima del turno 1, una combinazione che non è mai esistita. Conta anche l'ordine. Per un file toccato in più turni, i byte giusti sono la pre-image più vecchia, quella catturata all'inizio del turno 1. Procedere dal più recente al più vecchio porta alla pre-image del turno 2, che è lo stato prodotto dal turno 1. Il sorgente Rust ha un commento proprio su questo: all'inizio era stato scritto dal più recente al più vecchio, e il test rewinding_a_turn_also_undoes_later_turns se n'è accorto.

Il controllo del percorso viene ripetuto al momento del ripristino perché index.json è un normale file dentro la cartella che descrive. Se ci si fidasse, chiunque o qualunque cosa possa modificarlo potrebbe far scrivere a un ripristino in qualsiasi punto del disco. Poi i turni annullati vengono tolti dall'indice, così un secondo ritorno allo stesso punto non può "riuscire" su file che sono già tornati come prima.

Quanto costa su una cartella reale

L'albero è una copia della cartella src di una vera app React e TypeScript: 4,686 file, 121.4 MB, nessun file sopra gli 8 MB. A ogni turno sono stati scelti file a caso, catturati e allungati di una riga, poi è stato eseguito un ripristino e ogni file è stato confrontato con l'originale. Tutti i ripristini corrispondevano. Apple M1, 8 GB, macOS 15, SSD APFS, Python 3.9.6, git 2.39.5.

Turno Cattura + scrittura Dimensione dello store Ripristino
4 file + 1 creato 3.5 ms meno di 0.1 MB 1.2 ms
50 file + 1 creato 62.4 ms 0.9 MB 8.2 ms
500 file + 1 creato 1,035 ms 11.7 MB 227 ms
tutti i 4,686 file 30.5 s 122.7 MB 1.3 s

Per confronto, lo snapshot git per turno dello scenario precedente (git add -A e poi git stash create) ha richiesto da 61 a 68 ms su questo albero senza alcuna modifica, e da 50 a 67 ms con quattro file modificati. Deve guardare tutti i 4,686 file ogni volta. Subito dopo l'esecuzione che aveva riscritto tutti i file, la successiva ha richiesto 1,241 ms, perché git ricalcola l'hash di ogni file il cui timestamp è cambiato. Un checkpoint guarda sempre e solo i file che vengono scritti, quindi un turno normale costa pochi millisecondi qualunque sia la dimensione del progetto.

L'ultima riga mette in luce un problema nel codice così com'è scritto. La cattura media ha richiesto 1.29 ms per i primi 100 file del turno e 9.54 ms per gli ultimi 100. Ogni cattura riscrive l'intero index.json, arrivato a 1.3 MB, quindi il costo di ogni scrittura cresce con il numero di scritture precedenti. Sostituire _save con l'append di una riga JSON per cattura ha risolto il problema sugli stessi 4,686 file: 2.5 s in totale, 0.41 ms per cattura all'inizio e 0.61 ms alla fine. Per un agente che modifica una manciata di file per turno, la versione semplice va bene. Per un formatter o una rinomina su tutta la codebase fatta passare da un agente, usa il log append-only.

Cosa fa risparmiare l'indirizzamento per contenuto, e cosa no

Dare ai blob il nome del loro SHA-256 deduplica i byte identici e nient'altro. Tre misure mostrano dove si arriva.

Su tutto l'albero, 4,686 file avevano 4,677 contenuti distinti, quindi un turno sull'albero completo ha salvato 4,677 blob. In un vero albero di sorgenti i file duplicati sono rari.

Un file da 2 MB riscritto in dieci turni, con l'agente che aggiunge una riga ogni volta: dieci pre-image, tutte diverse, 10 blob e 20.0 MB. Stesso file, ma l'agente scrive gli stessi byte a ogni turno, come fa un retry che produce un output identico: 2 blob e 4.0 MB, l'originale e la versione da cui partiva ogni turno successivo. In entrambi i casi, tornare al primo turno ha restituito l'originale byte per byte.

I blob sono salvati così come sono, senza compressione. I 121.4 MB di blob del turno sull'albero completo diventerebbero 33.7 MB con zlib a livello 6, e l'object store di git per lo stesso albero dopo un commit era di 41.0 MB. La compressione è un'aggiunta sensata se lo store viene tenuto a lungo, visto che il codice sorgente si comprime circa 3.6 a 1. I delta tra versioni, come nei pack file di git, farebbero risparmiare di più nel caso dell'aggiunta di una riga, ma costano molto più codice di quanto serva a uno store di pre-image.

Limiti di questa versione

Vede solo le scritture che passano da write(). Se il tuo agente può anche eseguire comandi shell, un rm o un sed -i passano senza essere visti. L'app osFoundry fa passare le scritture su file dell'agente da un'unica funzione, ed è lì che cattura.

Un ripristino scrive la pre-image qualunque cosa ci sia ora nel file. Se hai modificato un file a mano dopo l'agente, tornare indietro su quel turno sostituisce anche la tua modifica. Una versione più rigorosa registra l'hash di quello che ha scritto l'agente e, prima di ripristinare, controlla se il file ha ancora quell'hash. Se no, salva prima i byte attuali come un blob in più, così anche il ripristino si può annullare.

Inoltre lo store cresce finché non si torna indietro su qualche turno. Basta un prune che cancelli i blob a cui nessun checkpoint fa riferimento, più un limite al numero di turni da tenere.

I due blocchi della classe qui sopra sono l'intero store, Python 3.9 con la libreria standard e niente da installare. L'app desktop di osFoundry include la versione Rust come /rewind nelle chat che lavorano in una cartella locale, gratis dalla pagina di download di osFoundry.