Wenn ein KI-Agent Dateien in deinem Projekt bearbeitet und dabei etwas kaputt macht, ist der Reflex git checkout -- . oder git stash. Beide beantworten eine andere Frage als die, die du hast. Git bringt dich zurück zum letzten Commit. Was du willst, ist der Ordner genau so, wie er direkt vor dem Turn des Agenten war: mit deiner eigenen, noch nicht committeten Arbeit und den Dateien, die git ignoriert, und ohne die Dateien, die der Agent angelegt hat.
Die osFoundry-Desktop-App löst das mit einem kleinen inhaltsadressierten Speicher in Rust, checkpoint_store.rs. Bevor ein Schreibvorgang des Agenten eine Datei erreicht, werden ihre aktuellen Bytes unter <project>/.osfoundry/checkpoints/blobs/<sha256> gesichert, und /rewind schreibt sie zurück. Unten steht dasselbe Design in etwa 90 Zeilen Python mit reiner Standardbibliothek, getestet gegen vier git-Rezepte an einem realistischen Turn und gemessen an einer Kopie eines echten TypeScript-Quellbaums mit 4,686 Dateien.
Ein Turn, fünf Arten, ihn rückgängig zu machen
Das Testprojekt ist eine winzige Website in einem git-Repository: index.html, styles.css (212 Zeilen), app.js und eine .env, die .gitignore ausschließt. Nach dem letzten Commit hat der Nutzer den Seitentitel in index.html geändert und eine ungetrackte notes.md angefangen. Das ist der Zustand, zu dem es zurückgehen soll.
Dann der Turn des Agenten: Er schreibt styles.css auf 41 Zeilen zusammen, ändert index.html so, dass eine neue theme.css geladen wird, schreibt app.js neu, lässt API_URL in .env auf localhost zeigen und legt theme.css an. Jede Undo-Methode lief auf einer frischen Kopie, und ein Skript verglich jede Datei Byte für Byte mit dem Zustand vor dem Turn.
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'] | Methode | Was schiefgeht |
|---|---|
git checkout -- . | Die Titeländerung des Nutzers geht zusammen mit denen des Agenten verloren, theme.css bleibt, .env behält den Wert des Agenten |
git stash -u | Die Änderungen des Agenten, die Titeländerung des Nutzers und notes.md landen alle zusammen in einem Stash, und .env behält den Wert des Agenten |
git checkout + git clean -fd | theme.css wird entfernt, aber notes.md auch, die Titeländerung ist weg, und .env behält den Wert des Agenten |
git stash create vor dem Turn | Alles Getrackte kommt zurück, aber theme.css bleibt, und .env ist nie im Snapshot |
| Checkpoint-Wiederherstellung | Entspricht dem Zustand vor dem Turn |
Die git-Ergebnisse folgen daraus, was git ist. Es stellt den letzten Commit wieder her oder einen Snapshot der getrackten und gestagten Dateien, und beides vermischt deine Arbeit mit der des Agenten, sobald keine von beiden committet ist. Ignorierte Dateien sieht es nicht, außer du gibst -a mit, und ein Checkout löscht nie eine Datei, die im Snapshot nicht existierte. Die letzte Zeile ist das fairste git-Setup, ein Snapshot vor jedem Turn, und trotzdem verfehlt es zwei der fünf Dateien. Dazu kommt ein Nebeneffekt: git add -A staged bei jedem Turn deine gesamte Arbeit im echten Index.
Der Speicher
Ein Checkpoint ist eine Liste pro Turn: Für jede Datei, die der Agent geschrieben hat, steht dort entweder der Hash ihres alten Inhalts oder ein Vermerk, dass sie nicht existierte. Die Bytes landen einmal pro unterschiedlichem Inhalt in blobs/.
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) Drei Details entscheiden darüber, ob eine Wiederherstellung korrekt ist, und jedes davon ist auch im Rust-Modul eine Regel.
Die erste Erfassung eines Pfads in einem Turn gewinnt. Ein Agent schreibt dieselbe Datei oft mehrmals in einem Turn. Zurückspulen heißt, zu dem Stand zurückzukehren, den die Datei zu Beginn des Turns hatte, nicht zu irgendeinem Zwischenstand zwischen zwei Schreibvorgängen des Agenten. Spätere Erfassungen desselben Pfads im selben Turn werden deshalb ignoriert.
Eine fehlende Datei wird als absent vermerkt. Genau diese eine Zeile erlaubt es einer Wiederherstellung, theme.css zu löschen. Git hat kein Gegenstück zu "diese Datei darf nicht existieren", außer einem git clean, das auch Dateien löscht, die du behalten wolltest.
Dateien über 8 MB werden mit Begründung als skipped vermerkt, nicht einfach weggelassen. Eine Wiederherstellung meldet dann, dass sie sie nicht zurückholen konnte, statt still mit einer Mischung aus alten und neuen Dateien fertig zu werden.
Wiederherstellen, die ältesten zuerst
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) Wer Turn 1 zurückspult, nachdem die Turns 2 und 3 gefolgt sind, muss alle drei rückgängig machen. Würde man nur Turn 1 wiederherstellen, lägen Dateien aus Turn 2 neben Dateien von vor Turn 1, eine Kombination, die es nie gab. Auch die Reihenfolge zählt. Für eine Datei, die in mehreren Turns angefasst wurde, sind die richtigen Bytes das älteste Pre-Image, also das, das beim Start von Turn 1 erfasst wurde. Geht man von neu nach alt vor, landet man beim Pre-Image von Turn 2, und das ist der Zustand, den Turn 1 erzeugt hat. Im Rust-Quellcode steht genau dazu ein Kommentar: Zuerst wurde es von neu nach alt geschrieben, und der Test rewinding_a_turn_also_undoes_later_turns hat es aufgedeckt.
Die Pfadprüfung läuft beim Wiederherstellen erneut, weil index.json eine ganz normale Datei in dem Ordner ist, den sie beschreibt. Würde man ihr vertrauen, könnte jeder und alles, was sie bearbeiten kann, eine Wiederherstellung an eine beliebige Stelle auf der Festplatte schreiben lassen. Danach werden die zurückgespulten Turns aus dem Index entfernt, damit ein zweites Zurückspulen auf denselben Punkt nicht gegen Dateien "erfolgreich" sein kann, die längst wieder da sind.
Was es in einem echten Ordner kostet
Der Baum ist eine Kopie des src-Ordners einer echten React- und TypeScript-App: 4,686 Dateien, 121.4 MB, keine Datei über 8 MB. In jedem Turn wurden zufällige Dateien ausgewählt, erfasst und um eine Zeile ergänzt, danach lief eine Wiederherstellung, und jede Datei wurde mit dem Original verglichen. Alle Wiederherstellungen stimmten. Apple M1, 8 GB, macOS 15, APFS-SSD, Python 3.9.6, git 2.39.5.
| Turn | Erfassen + Schreiben | Speichergröße | Wiederherstellen |
|---|---|---|---|
| 4 Dateien + 1 neu angelegt | 3.5 ms | unter 0.1 MB | 1.2 ms |
| 50 Dateien + 1 neu angelegt | 62.4 ms | 0.9 MB | 8.2 ms |
| 500 Dateien + 1 neu angelegt | 1,035 ms | 11.7 MB | 227 ms |
| alle 4,686 Dateien | 30.5 s | 122.7 MB | 1.3 s |
Zum Vergleich: Der git-Snapshot pro Turn aus dem Szenario oben (git add -A, dann git stash create) brauchte auf diesem Baum ohne Änderungen 61 bis 68 ms und mit vier bearbeiteten Dateien 50 bis 67 ms. Er muss jedes Mal alle 4,686 Dateien ansehen. Direkt nach dem Lauf, der jede Datei neu geschrieben hatte, brauchte der nächste 1,241 ms, weil git jede Datei neu hasht, deren Zeitstempel sich geändert hat. Ein Checkpoint sieht sich immer nur die Dateien an, die gerade geschrieben werden, also kostet ein normaler Turn ein paar Millisekunden, egal wie groß das Projekt ist.
Die letzte Zeile deckt ein Problem im Code in dieser Form auf. Die durchschnittliche Erfassung dauerte bei den ersten 100 Dateien des Turns 1.29 ms und bei den letzten 100 9.54 ms. Jede Erfassung schreibt das komplette index.json neu, das auf 1.3 MB anwuchs, also steigen die Kosten jedes Schreibvorgangs mit der Zahl der Schreibvorgänge davor. _save durch das Anhängen einer JSON-Zeile pro Erfassung zu ersetzen, hat das auf denselben 4,686 Dateien behoben: 2.5 s insgesamt, 0.41 ms pro Erfassung am Anfang und 0.61 ms am Ende. Für einen Agenten, der pro Turn eine Handvoll Dateien bearbeitet, reicht die einfache Version. Für einen Formatter oder ein Umbenennen quer durch die Codebasis, das über einen Agenten läuft, nimm das Append-only-Log.
Was Inhaltsadressierung spart und was nicht
Blobs nach ihrem SHA-256 zu benennen, dedupliziert identische Bytes und sonst nichts. Drei Messungen zeigen, wo das landet.
Über den ganzen Baum hatten 4,686 Dateien 4,677 unterschiedliche Inhalte, ein Turn über den kompletten Baum speicherte also 4,677 Blobs. Doppelte Dateien sind in einem echten Quellbaum selten.
Eine 2-MB-Datei, in zehn Turns neu geschrieben, wobei der Agent jedes Mal eine Zeile anhängt: zehn Pre-Images, alle verschieden, 10 Blobs und 20.0 MB. Dieselbe Datei, aber der Agent schreibt in jedem Turn dieselben Bytes, wie es ein Retry mit identischer Ausgabe tut: 2 Blobs und 4.0 MB, das Original und die Version, von der jeder spätere Turn ausging. In beiden Fällen lieferte das Zurückspulen auf den ersten Turn das Original Byte für Byte zurück.
Die Blobs werden unkomprimiert gespeichert. Die 121.4 MB an Blobs aus dem Turn über den kompletten Baum kämen mit zlib auf Stufe 6 auf 33.7 MB, und gits eigener Objektspeicher für denselben Baum nach einem Commit lag bei 41.0 MB. Kompression ist eine sinnvolle Ergänzung, wenn der Speicher lange aufbewahrt wird, denn Quellcode komprimiert etwa 3.6 zu 1. Deltas zwischen Versionen wie in gits Packfiles würden im Fall "eine Zeile anhängen" mehr sparen, kosten aber weit mehr Code, als ein Pre-Image-Speicher braucht.
Grenzen dieser Version
Sie sieht nur Schreibvorgänge, die über write() gehen. Wenn dein Agent auch Shell-Befehle ausführen darf, rutscht ein rm oder ein sed -i einfach durch. Die osFoundry-App leitet die Dateischreibvorgänge des Agenten durch eine einzige Funktion, und genau dort erfasst sie.
Eine Wiederherstellung schreibt das Pre-Image zurück, egal was gerade in der Datei steht. Wenn du eine Datei nach dem Agenten von Hand bearbeitet hast, ersetzt das Zurückspulen dieses Turns auch deine Änderung. Eine strengere Version vermerkt den Hash dessen, was der Agent geschrieben hat, und prüft vor dem Wiederherstellen, ob die Datei noch diesen Hash hat. Wenn nicht, sichert sie zuerst die aktuellen Bytes als weiteren Blob, sodass sich auch die Wiederherstellung rückgängig machen lässt.
Außerdem wächst der Speicher, bis Turns zurückgespult werden. Ein prune, das Blobs löscht, auf die kein Checkpoint mehr verweist, plus eine Obergrenze für die Zahl der aufbewahrten Turns genügt.
Die beiden Klassenblöcke oben sind der komplette Speicher, Python 3.9 mit reiner Standardbibliothek, nichts zu installieren. Die Desktop-App von osFoundry liefert die Rust-Version als /rewind in Chats, die in einem lokalen Ordner arbeiten, kostenlos über die Download-Seite von osFoundry.
