AI 에이전트가 프로젝트 파일을 편집하여 무언가를 망가뜨렸을 때, 가장 먼저 하는 습관은 git checkout -- . 또는 git stash를 실행하는 것입니다. 하지만 이 두 가지 방법은 여러분이 실제로 묻지도 않은 질문에 대한 답을 제공합니다. Git은 여러분을 마지막 커밋(commit) 상태로 되돌릴 수는 있습니다. 필요한 것은 에이전트가 작업하기 직전의 폴더 상태입니다: 즉, 사용자의 자체적인 커밋되지 않은 작업물과 git이 무시하는 파일들은 그대로 유지되고, 에이전트가 만든 파일들은 사라져야 합니다.
osFoundry 데스크톱 앱은 이를 Rust로 작성된 작은 콘텐츠 주소 지정 저장소(content-addressed store), checkpoint_store.rs를 통해 해결합니다. 에이전트가 파일에 쓰기 작업을 하기 전에, 해당 파일의 기존 바이트는 <project>/.osfoundry/checkpoints/blobs/<sha256>에 저장되고, /rewind가 이를 다시 기록(write)합니다. 아래에는 동일한 디자인을 표준 라이브러리 Python으로 약 90줄 분량으로 구현한 내용이 있으며, 실제와 같은 턴(turn) 상황에서 네 가지 git 방식과 비교되었고, 4,686개의 파일로 구성된 실제 TypeScript 소스 트리 복사본에 대해 시간이 측정되었습니다.
한 턴(turn)을 되돌리는 다섯 가지 방법
테스트 프로젝트는 git 저장소 안에 있는 작은 웹사이트입니다: index.html, styles.css (212줄), app.js, 그리고 .gitignore에 의해 제외되는 .env 파일이 있습니다. 사용자는 마지막 커밋 이후 index.html의 페이지 제목을 변경하고, 추적되지 않은(untracked) notes.md 파일을 작성하기 시작했습니다. 이 상태로 되돌아가야 합니다.
다음으로 에이전트가 작업을 수행합니다: styles.css를 41줄로 축소하여 다시 작성하고, index.html을 편집하여 새로운 theme.css를 로드하게 하며, app.js를 다시 작성하고, .env 파일의 API_URL을 localhost로 지정하며, theme.css 파일을 생성합니다. 모든 되돌리기 방법은 새 복사본에 대해 실행되었으며, 스크립트는 각 파일이 턴(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 -- . | 사용자(user)의 title 변경 사항이 에이전트의 변경 사항과 섞여 들어가고, theme.css는 유지되며, .env 파일에는 에이전트가 설정한 값이 남아 있습니다. |
| ... | |
git의 이러한 결과는 Git이 작동하는 방식 자체에 기인합니다. Git은 마지막 커밋(commit)이나 추적되고 스테이징된 파일들의 스냅샷을 복원하며, 실제로 커밋되지 않는 한 이 두 가지 모두 사용자의 작업과 에이전트의 작업을 혼합해 버립니다. 무시된(Ignored) 파일들은 -a 옵션을 추가하지 않으면 Git에게 보이지 않으며, checkout은 스냅샷에 존재했던 적 없는 파일을 절대 삭제하지 않습니다. 마지막 줄은 Git에게 가장 공정한 설정입니다. 즉, 매 턴(turn) 시작 전에 찍힌 스냅샷이지만, 그럼에도 불구하고 다섯 개 중 두 개의 파일은 놓치게 됩니다. 이것에는 부작용도 있습니다: 매 턴마다 git add -A를 사용하면 모든 작업이 실제 인덱스에 스테이징됩니다. | |
Store
체크포인트(Checkpoint)는 매 턴에 대한 목록입니다. 에이전트가 작성한 파일 각각에 대해 이전 콘텐츠의 해시값(hash)을 기록하거나, 해당 파일이 존재하지 않았다는 메모를 남깁니다. 바이트(Bytes)는 모든 고유한 콘텐츠에 대해 한 번씩 blobs/ 디렉터리로 이동합니다.
import hashlib, json, os, time
from pathlib import Path
...
복원(Restore)이 올바른지 여부는 세 가지 요소에 달려 있으며, 각 Rust 모듈에도 규칙이 있습니다.
한 턴(turn)에서 특정 경로(path)의 첫 번째 캡처만 유효합니다. 에이전트는 종종 같은 턴에 동일한 파일을 여러 번 작성합니다. 리와인드(Rewind)는 파일이 턴을 시작할 때의 상태로 되돌리는 것을 의미하며, 에이전트가 수행한 두 쓰기 작업 사이의 어떤 상태를 의미하는 것이 아닙니다. 따라서 같은 턴 내에서 동일 경로에 대한 이후의 캡처들은 무시됩니다.
존재하지 않았던 파일은 absent로 기록됩니다. 이 한 줄이 복원(restore) 과정에서 theme.css를 삭제할 수 있게 합니다. Git에는 "이 파일은 존재해서는 안 된다"라는 기능이 없으며, 오직 git clean만이 그러한 기능을 수행하는데, 이는 사용자가 유지하고 싶었던 파일들까지도 삭제해 버립니다.
8 MB보다 큰 파일들은 삭제되지 않고, 이유와 함께 skipped로 기록됩니다. 그런 다음 복원(restore) 과정에서 조용히 이전 및 새 파일들의 혼합을 남기고 끝나는 대신, 해당 파일을 되돌릴 수 없었음을 알려줍니다.
Restore, 가장 오래된 것부터 시작하여
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),
...
만약 turn 1 이후에 turn 2와 turn 3이 발생했다면, turn 1로 되감기(rewind)할 때 세 가지 모두가 취소되어야 합니다. 단순히 turn 1을 복원하면, turn 2의 파일들은 turn 1 이전의 파일들과 함께 남아 있게 되는데, 이는 존재했던 적 없는 조합입니다. 순서 또한 중요합니다. 여러 turn에 걸쳐 수정된 파일의 정확한 바이트는 가장 오래된 pre-image입니다. 즉, turn 1이 시작될 때 포착된 상태를 말합니다. 가장 최신 상태에서 거꾸로 이동하면, 사실상 turn 1이 만들어낸 상태인 turn 2의 pre-image에 도달하게 됩니다. Rust 소스 코드에는 이와 관련하여 주석이 있습니다: 원래는 newest-first(최신순)으로 작성되었으며, 테스트 rewinding_a_turn_also_undoes_later_turns가 이를 잡아냈습니다.
restore 시점에서는 path check가 다시 실행됩니다. 이는 index.json이 해당 설명서 역할을 하는 같은 폴더 안에 놓인 단순한 파일이기 때문입니다. 이 파일에 대한 신뢰도가 생기면, 파일을 편집할 수 있는 모든 사람이나 프로그램은 restore 기능을 이용해 디스크의 어느 곳이든 데이터를 쓸 수 있게 됩니다. 이후 되감긴 turn들은 인덱스에서 제거되어, 동일 지점으로 다시 되돌리려고 할 때 이미 복원된 파일들 위에 '성공적'으로 덮어쓰는 것을 방지합니다.
실제 폴더에서의 비용
Tree는 실제 React 및 TypeScript 앱의 src 폴더 사본입니다: 4,686개의 파일, 121.4 MB 크기이며, 어떤 파일도 8 MB를 초과하지 않습니다. 모든 turn은 무작위 파일을 선택하고, 이를 포착한 다음, 각 파일에 한 줄을 추가했습니다. 이후 restore가 실행되어 모든 파일이 원본 상태와 비교되었습니다. 모든 복원 과정은 일치했습니다. 사용된 환경은 Apple M1, 8 GB RAM, macOS 15, APFS SSD, Python 3.9.6, git 2.39.5였습니다.
| Turn | Capture + Write | Storage Size | Restore |
|---|
| 4 파일 + 1 신규 | 3.5 ms | 0.1 MB 미만 | 1.2 ms |
| ... | | | |
비교를 위해, 위 시나리오의 per-turn git snapshot (git add -A 후 git stash create)은 이 트리에 아무것도 변경되지 않았을 때 61ms에서 68ms가 걸렸고, 네 개의 파일이 편집되었을 때는 50ms에서 67ms가 걸렸습니다. 이는 매번 모든 4,686개 파일을 확인해야 하기 때문입니다. 모든 파일을 다시 작성하는 run 직후의 다음 snapshot은 1,241ms가 걸렸는데, 이는 git이 타임스탬프(timestamp)가 변경된 모든 파일을 재해시(re-hash)하기 때문입니다. Checkpoint는 기록되고 있는 파일들만 확인하므로, 프로젝트 규모와 관계없이 일반적인 turn에서는 몇 밀리초(milliseconds)밖에 걸리지 않습니다. | | | |
마지막 줄은 기존 코드의 문제점을 보여줍니다. 해당 turn에서 처음 100개 파일에 대한 평균 capture 시간은 1.29ms였고, 마지막 100개 파일은 9.54ms였습니다. 모든 capture는 전체 index.json을 다시 작성하며, 이 파일은 최대 1.3MB까지 커졌기 때문에, 매번 쓰기(write) 비용이 그 이전에 발생한 write 횟수에 따라 증가합니다. _save 대신 각 capture마다 JSON 라인을 추가(append)하는 방식으로 변경하자, 동일한 4,686개 파일에 대해 다음과 같이 개선되었습니다: 총 2.5초가 걸렸으며, 시작 시에는 capture당 0.41ms였고 끝날 때는 0.61ms였습니다. 에이전트가 turn마다 소수의 파일만 편집하는 경우, 단순 버전(simple version)으로 충분합니다. 만약 에이전트에게 포매터(formatter)를 적용하거나 전체 코드베이스에서 이름 변경(rename)을 수행하게 하려면 append-only log를 사용하십시오.
Content Addressing로 무엇이 절약되고 무엇이 절약되지 않는가
Blobs의 이름을 그들의 SHA-256 해시값으로 지정하면, 같은 바이트는 중복 제거(deduplicate)됩니다. 이것이 얼마나 큰 영향을 미치는지 보여주는 세 가지 측정이 있습니다.
전체 트리에 4,686개의 파일이 있었고 4,677개의 고유한 내용물(contents)을 가졌기 때문에, 전체 트리 turn은 4,677개의 blobs를 저장했습니다. 실제 소스 트리에서는 중복된 파일이 적습니다.
2 MB 파일을 열 번의 턴(turns)에 걸쳐 다시 작성했고, 에이전트는 매번 한 줄씩 추가했습니다: 열 개의 사전 이미지(pre-images), 모두 다르고, 10개의 블롭(blobs)과 총 20.0 MB가 되었습니다. 같은 파일이지만, 에이전트는 매 턴마다 동일한 바이트를 기록합니다. 이는 재시도(retry) 시 완전히 동일한 출력을 제공할 때 발생하는 상황과 같습니다: 2개의 블롭과 4.0 MB입니다. 즉, 원본 버전과 이후 모든 턴이 시작된 그 버전을 의미합니다. 두 경우 모두 첫 번째 턴으로 되돌리면 원래의 바이트 단위로 복구되었습니다.
블롭은 원시(raw) 상태로 저장됩니다. 전체 트리가 포함된 턴의 블롭은 zlib level 6을 거치면 121.4 MB에서 33.7 MB가 되었고, 같은 트리 커밋 이후 git 자체 오브젝트 스토어는 41.0 MB였습니다. 스토어를 장기간 유지하려면 압축 기능을 추가하는 것이 좋습니다. 왜냐하면 소스 코드는 약 3.6 대 1의 비율로 압축되기 때문입니다. Git의 패크 파일(pack files)처럼 버전 간에 델타(deltas)를 유지하면 한 줄을 추가하는 경우 더 많은 절약이 가능하겠지만, 그러기 위해서는 사전 이미지 스토어(pre-image store)가 필요 이상으로 많은 코드가 들어갑니다.
이 버전의 제한 사항
이는 write() 함수를 통해 전달되는 쓰기 작업만 확인합니다. 만약 에이전트가 셸 명령어(shell commands)도 실행할 수 있다면, rm이나 sed -i 같은 명령은 직접적으로 우회됩니다. osFoundry 앱은 에이전트의 모든 파일 쓰기를 단일 함수를 통해 보내고, 캡처는 그곳에서 이루어집니다.
복원(Restore) 파일에는 현재 무엇이 있든 간에 사전 이미지(pre-image)가 기록됩니다. 만약 에이전트 이후에 사용자가 수동으로 파일을 편집했다면, 해당 턴을 되돌릴 때 사용자 변경 사항도 덮어쓰여질 것입니다. 더 강력한 버전은 에이전트가 작성한 콘텐츠의 해시(hash)를 기록하고, 복원 전에 파일에 여전히 같은 해시가 남아 있는지 확인합니다. 그렇지 않다면, 복원 과정에서도 취소될 수 있도록 기존 바이트를 또 다른 블롭으로 저장합니다.
스토어는 턴이 되돌려질 때까지 계속 증가합니다. 어떤 체크포인트(checkpoint)도 참조하지 않는 블롭을 삭제하는 prune 기능과 보관할 턴의 개수에 대한 제한만 있다면 충분합니다.
위에 있는 두 개의 클래스 블록만 전체 스토어이며, 표준 라이브러리 Python 3.9를 사용하므로 아무것도 설치할 필요가 없습니다. osFoundry 데스크톱 앱 (Rust 버전)은 로컬 폴더에서 작동하는 채팅에 /rewind 기능을 제공하며, 이는 osFoundry download page에서 무료로 다운로드할 수 있습니다.