Saat agen AI mengedit file di proyekmu dan merusak sesuatu, refleksnya adalah git checkout -- . atau git stash. Keduanya menjawab pertanyaan yang berbeda dari yang sebenarnya kamu punya. Git bisa membawamu kembali ke commit terakhir. Yang kamu inginkan adalah folder seperti tepat sebelum giliran agen dimulai: pekerjaanmu sendiri yang belum di-commit dan file yang diabaikan git tetap ada, sementara file yang dibuat agen tidak ada.

Aplikasi desktop osFoundry menyelesaikan ini dengan store kecil berbasis alamat konten (content-addressed) yang ditulis dengan Rust, checkpoint_store.rs. Sebelum tulisan agen sampai ke sebuah file, byte file saat itu disimpan di <project>/.osfoundry/checkpoints/blobs/<sha256>, dan /rewind menuliskannya kembali. Di bawah ini desain yang sama dalam sekitar 90 baris Python pustaka standar, diuji melawan empat resep git pada satu giliran yang realistis, dan diukur waktunya pada salinan source tree TypeScript nyata berisi 4.686 file.

Satu giliran, lima cara membatalkannya

Proyek uji coba adalah situs kecil di dalam repositori git: index.html, styles.css (212 baris), app.js, dan .env yang dikecualikan oleh .gitignore. Setelah commit terakhir, pengguna mengubah judul halaman di index.html dan mulai menulis notes.md yang belum dilacak. Itulah kondisi yang ingin dikembalikan.

Lalu giliran agen: menulis ulang styles.css menjadi 41 baris, mengedit index.html agar memuat theme.css baru, menulis ulang app.js, mengarahkan API_URL di .env ke localhost, dan membuat theme.css. Setiap metode pembatalan dijalankan pada salinan baru, dan sebuah skrip membandingkan setiap file byte demi byte dengan kondisi sebelum giliran.

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'] 
Metode Apa yang salah
git checkout -- . Edit judul milik pengguna hilang bersama edit agen, theme.css tetap ada, .env masih berisi nilai dari agen
git stash -u Perubahan agen, edit judul pengguna, dan notes.md semuanya masuk ke satu stash yang sama, dan .env masih berisi nilai dari agen
git checkout + git clean -fd theme.css terhapus, tapi notes.md ikut terhapus, edit judul hilang, dan .env masih berisi nilai dari agen
git stash create sebelum giliran Semua file yang dilacak kembali, tapi theme.css tetap ada dan .env tidak pernah masuk snapshot
Restore checkpoint Sama dengan kondisi sebelum giliran

Hasil git ini memang konsekuensi dari cara git bekerja. Git memulihkan commit terakhir, atau snapshot file yang dilacak dan di-stage, dan keduanya mencampur pekerjaanmu dengan pekerjaan agen begitu belum ada yang di-commit. File yang diabaikan tidak terlihat oleh git kecuali kamu menambahkan -a, dan checkout tidak pernah menghapus file yang tidak ada di snapshot. Baris terakhir adalah setup git yang paling adil, yaitu snapshot yang diambil sebelum setiap giliran, dan tetap saja melewatkan dua dari lima file. Ada efek samping juga: git add -A men-stage semua pekerjaanmu di index yang sebenarnya pada setiap giliran.

Store-nya

Checkpoint adalah daftar per giliran: untuk setiap file yang ditulis agen, isinya hash dari konten lamanya, atau catatan bahwa file itu belum ada. Byte-nya masuk ke blobs/ satu kali per konten yang berbeda.

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) 

Tiga detail menentukan apakah restore benar, dan masing-masing juga menjadi aturan di modul Rust.

Capture pertama untuk sebuah path dalam satu giliran yang dipakai. Agen sering menulis file yang sama beberapa kali dalam satu giliran. Rewind berarti kembali ke kondisi file saat giliran dimulai, bukan ke suatu kondisi di antara dua tulisan agen itu sendiri, jadi capture berikutnya untuk path yang sama dalam giliran yang sama diabaikan.

File yang belum ada dicatat sebagai absent. Satu baris itulah yang membuat restore bisa menghapus theme.css. Git tidak punya padanan untuk "file ini tidak boleh ada", selain git clean yang juga menghapus file yang ingin kamu simpan.

File di atas 8 MB dicatat sebagai skipped beserta alasannya, bukan dibuang begitu saja. Dengan begitu restore melaporkan bahwa file itu tidak bisa dikembalikan, alih-alih diam-diam selesai dengan campuran file lama dan baru.

Restore, mulai dari yang paling lama

 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) 

Rewind ke giliran 1 saat giliran 2 dan 3 datang sesudahnya harus membatalkan ketiganya. Kalau hanya giliran 1 yang dipulihkan, file dari giliran 2 akan berdampingan dengan file dari sebelum giliran 1, kombinasi yang tidak pernah ada. Urutannya juga penting. Untuk file yang disentuh di beberapa giliran, byte yang benar adalah pre-image paling lama, yaitu yang di-capture saat giliran 1 dimulai. Kalau ditelusuri dari yang terbaru, hasilnya berhenti di pre-image giliran 2, yang justru merupakan kondisi hasil giliran 1. Source Rust punya komentar tentang persis hal ini: awalnya ditulis dari yang terbaru, dan test rewinding_a_turn_also_undoes_later_turns yang menangkapnya.

Pengecekan path dijalankan lagi saat restore karena index.json hanyalah file biasa di dalam folder yang dideskripsikannya. Kalau file itu dipercaya begitu saja, siapa pun atau apa pun yang bisa mengeditnya bisa membuat restore menulis ke mana saja di disk. Giliran yang sudah di-rewind lalu dihapus dari index, jadi rewind kedua ke titik yang sama tidak bisa "berhasil" terhadap file yang sudah kembali.

Berapa biayanya di folder nyata

Tree yang dipakai adalah salinan folder src dari aplikasi React dan TypeScript nyata: 4.686 file, 121,4 MB, tidak ada file di atas 8 MB. Setiap giliran memilih file secara acak, meng-capture-nya, lalu menambahkan satu baris ke masing-masing file, kemudian restore dijalankan dan setiap file dibandingkan dengan aslinya. Semua restore cocok. Apple M1, 8 GB, macOS 15, SSD APFS, Python 3.9.6, git 2.39.5.

Giliran Capture + tulis Ukuran store Restore
4 file + 1 dibuat 3,5 ms di bawah 0,1 MB 1,2 ms
50 file + 1 dibuat 62,4 ms 0,9 MB 8,2 ms
500 file + 1 dibuat 1.035 ms 11,7 MB 227 ms
semua 4.686 file 30,5 s 122,7 MB 1,3 s

Sebagai perbandingan, snapshot git per giliran dari skenario di atas (git add -A lalu git stash create) butuh 61 sampai 68 ms di tree ini saat tidak ada yang berubah, dan 50 sampai 67 ms dengan empat file yang diedit. Git harus memeriksa semua 4.686 file setiap kali. Tepat setelah run yang menulis ulang semua file, snapshot berikutnya butuh 1.241 ms, karena git menghitung ulang hash setiap file yang timestamp-nya berubah. Checkpoint hanya melihat file yang sedang ditulis, jadi giliran normal hanya makan beberapa milidetik, berapa pun ukuran proyeknya.

Baris terakhir memperlihatkan masalah pada kode seperti yang tertulis. Rata-rata capture butuh 1,29 ms untuk 100 file pertama dalam giliran itu dan 9,54 ms untuk 100 file terakhir. Setiap capture menulis ulang seluruh index.json, yang ukurannya mencapai 1,3 MB, sehingga biaya tiap tulisan bertambah seiring jumlah tulisan sebelumnya. Mengganti _save dengan menambahkan satu baris JSON per capture memperbaikinya pada 4.686 file yang sama: total 2,5 s, 0,41 ms per capture di awal dan 0,61 ms di akhir. Untuk agen yang mengedit beberapa file per giliran, versi sederhana sudah cukup. Untuk formatter atau rename di seluruh codebase yang dijalankan lewat agen, pakai log append-only.

Apa yang dihemat content addressing, dan apa yang tidak

Menamai blob dengan SHA-256 menghilangkan duplikasi byte yang identik, dan tidak lebih dari itu. Tiga pengukuran menunjukkan seberapa besar efeknya.

Di seluruh tree, 4.686 file punya 4.677 konten yang berbeda, jadi giliran yang menyentuh seluruh tree menyimpan 4.677 blob. File duplikat jarang ada di source tree nyata.

Satu file 2 MB ditulis ulang dalam sepuluh giliran, dengan agen menambahkan satu baris setiap kali: sepuluh pre-image, semuanya berbeda, 10 blob dan 20,0 MB. File yang sama, tapi agen menulis byte yang sama di setiap giliran, seperti saat retry menghasilkan output yang identik: 2 blob dan 4,0 MB, yaitu file asli dan versi yang menjadi titik awal semua giliran berikutnya. Di kedua kasus, rewind ke giliran pertama mengembalikan file asli byte demi byte.

Blob disimpan mentah. Blob 121,4 MB dari giliran seluruh tree akan menjadi 33,7 MB dengan zlib level 6, dan object store git sendiri untuk tree yang sama setelah satu commit berukuran 41,0 MB. Kompresi masuk akal ditambahkan kalau store-nya disimpan lama, karena source code terkompresi sekitar 3,6 banding 1. Delta antarversi, seperti di pack file git, akan menghemat lebih banyak untuk kasus menambah satu baris, tapi butuh kode jauh lebih banyak daripada yang diperlukan store pre-image.

Batasan versi ini

Store ini hanya melihat tulisan yang lewat write(). Kalau agenmu juga bisa menjalankan perintah shell, rm atau sed -i akan lolos begitu saja. Aplikasi osFoundry mengarahkan semua penulisan file oleh agen lewat satu fungsi, dan di situlah capture dilakukan.

Restore menulis pre-image apa pun isi file saat ini. Kalau kamu mengedit file secara manual setelah agen, rewind giliran itu juga akan menimpa editmu. Versi yang lebih ketat mencatat hash dari apa yang ditulis agen, dan sebelum restore, memeriksa apakah file masih punya hash itu. Kalau tidak, byte saat ini disimpan dulu sebagai satu blob tambahan, sehingga restore-nya sendiri juga bisa dibatalkan.

Store juga terus membesar sampai giliran di-rewind. prune yang menghapus blob yang tidak dirujuk checkpoint mana pun, ditambah batas jumlah giliran yang disimpan, sudah cukup.

Dua blok class di atas adalah seluruh store-nya, Python 3.9 pustaka standar tanpa perlu menginstal apa pun. Aplikasi desktop osFoundry menyediakan versi Rust-nya sebagai /rewind di chat yang bekerja di folder lokal, gratis dari halaman unduhan osFoundry.