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.
Top comments (0)