all notes

rewinding agents

·alana goyal
0:00 / 14:15

Over the past year, I've gone from working with one coding agent at a time to running half a dozen in parallel. This multi-agent workflow often involves trying different approaches, keeping what works, and throwing the rest away. With the agentic coding tools of today and primitives like branches and worktrees, spinning up new agents is easy. But getting back to a clean starting point can be a pain, especially when state is involved.

Among others, Supabase has done some interesting work in this space. Their new database-per-worktree feature gives each worktree or branch its own local database, so one agent's database changes don't affect another's. But undoing an agent's code doesn't undo its database changes. If it changes the schema or deletes some rows, reverting the code leaves those database changes behind.

I built a tool called Forklift to solve this for myself. It builds upon Supabase's database-per-worktree idea by saving selected project files alongside a copy of the database's schema and data. When you rewind, Forklift restores both the code and the data together.

Below, I'll walk through how it works, then show it at work with parallel coding agents.

Starting with ordinary Postgres

Forklift is a Rust coordinator over unmodified PostgreSQL 17. Each branch has its own cluster files and server, connected through a private Unix socket. Postgres still handles SQL, transactions, its write-ahead log, and crash recovery with durability settings enabled. Projects can use their existing database names and supported extensions, including pg_trgm and vector.

To create a checkpoint, Forklift briefly stops Postgres, verifies shutdown, saves its files, and restarts it if it was running. These are physical checkpoints of the database, so restoring one doesn't require writing reverse migrations or SQL to undo individual row changes.

Saved checkpoints live in local object storage. Forklift splits the database files into pieces, identifies each piece by a hash of its contents, and stores them in compressed packs. A manifest records which pieces reconstruct each file. Each manifest describes a complete checkpoint, so restoring it doesn't require replaying earlier checkpoints, even when it reuses pieces saved earlier. Saved pieces never change, and identical pieces can be reused across checkpoints.

Forklift rebuilds and verifies the database files from a checkpoint, then keeps them in a stopped cache for reuse. Forklift uses APFS copy-on-write clones, so each branch gets its own copy of the database. The copies share stored data until a branch changes it, at which point the changed parts are stored separately. Postgres uses ordinary files, and creating another branch doesn't interrupt the original database.

Connecting a worktree to its database

forklift project is used to associate each worktree with its own database and keep that association consistent as the code and data change.

A .forklift.json file describes the application: which source paths to save, which environment variable carries the database URL, and which commands to run. For example:

{
  "format": 1,
  "source": ["src", "migrations"],
  "connection_env": "DATABASE_URL",
  "commands": {
    "verify": ["python3", "src/app.py"]
  }
}

For ordinary Postgres projects, the local store path and starting database checkpoint are configured separately on the machine. During setup, Forklift gives the worktree an identity and creates a distinct database branch from that checkpoint. The machine configuration and worktree records live under Git's administrative directories, outside the checked-out application files. This keeps local database paths out of the shared application configuration.

For an existing Supabase project, forklift project setup detects supabase/config.toml, creates a default .forklift.json to review, and uses the project's migrations and seed files to initialize a private local stack for each worktree. forklift project run -- npm run dev starts the services and passes the local API URL and keys through the usual Supabase and Next.js environment variables. You can configure different variable names if your app needs them. Each worktree gets an isolated local environment, so agents can experiment without affecting the hosted project.

When Forklift launches a command, it looks up the worktree's selected database branch, starts it, and supplies its connection URL through DATABASE_URL (or the configured variable). Ordinary Postgres projects can use a private Unix socket or opt into password-authenticated local TCP for drivers that need it. The command runs in the existing worktree directory. Forklift doesn't rewrite .env or require a Git commit or reset. Each command gets the currently selected database URL when it starts, so it connects to the right database after a restore.

Forklift requires commands it manages to finish before saving or restoring, while other worktrees can keep working. For long-running apps and workers, you can configure stop and start hooks so Forklift pauses them for the operation and restarts them afterward if they were running and the operation succeeds.

Saving and restoring code and data together

In order to be useful, a checkpoint needs to capture code and data at the same point in the agent's work. Forklift saves a copy of the selected files and links its hashes and permissions to an immutable database checkpoint in one named pair.

With unmanaged writers paused, forklift project capture holds the worktree's command lock, copies and verifies the source, and saves a cleanly stopped database checkpoint. It restarts Postgres if it was running and publishes the pair only after both are saved. Captures include uncommitted and untracked files within the selected paths. Everything outside those paths, along with Git history and the staging index, stays untouched.

For ordinary Postgres projects, forklift project restore prepares both sides before selecting them together. The paired engine's current.json record selects a source copy and database branch. The project adapter installs the files at the existing worktree path so the editor and agent don't need to move:

forklift project restore before_change

1. retain current files, write a recovery journal
2. prepare saved source + a new database branch, verify both
3. atomically select the pair in current.json
4. install source in the worktree, verify hashes, and clear the journal

Forklift selects the code and database together, but installing the code files can be interrupted. The recovery journal lets forklift project recover restore the previous pair if interrupted before selection, or finish installing the new pair afterward. Managed commands stay blocked until recovery finishes. Other processes that write to the files or database need to stay paused too. Old copies are retained, and the next managed command receives the selected database URL. Supabase captures also save local Auth records and uploaded Storage files alongside the code and database. They use stopped filesystem copies and APFS clones, with their own recovery journal, so restoring one rewinds those pieces together before restarting the services.

Once an experiment is finished, forklift project forget removes its named capture and forklift project prune reclaims obsolete retained state. Both commands preview their changes before --yes applies them, so cleaning up old experiments is an explicit part of the workflow.

Trying it with an agent

Once I had the basic version implemented, I built a small command-line invoicing app to test it out. I created three invoices, saved its starting state, and asked Codex to add invoice archiving to the program. The agent edited the app and ran a migration that added an archived column and marked one paid invoice as archived. As you’d expect, the updated app stopped listing the archived invoice. Then, once I restored the saved code and database, the app showed all three:

ObservationBefore agentAfter agentAfter rewind
App version121
Listed invoice IDs1, 2, 31, 21, 2, 3
archived columnAbsentPresentAbsent
Invoice 3Original rowArchivedOriginal row

The restored source files and their permissions, schema, rows, and app output all matched the starting state exactly. The original database and another branch's database stayed unchanged.

Trying it with parallel agents

Running multiple agents in parallel can be particularly useful when you want to quickly try and compare multiple approaches. Git worktrees give each experiment separate code, but each experiment needs its own database too. Otherwise, one agent’s migration or deleted row could affect the other. Independent databases let parallel agents try incompatible designs. Rewinding then allows you to return one experiment’s code and database to a saved state while keeping the other’s progress.

To showcase this, I asked two Codex agents to “support partial payments” in the invoicing app. They worked in parallel in two configured Git worktrees, each with its own Forklift database branch from the same starting state.

After both agents finished, both worktrees preserved the already-paid invoice and initially reported $175 outstanding. Then, I recorded a $25 payment in Agent B’s app against Ada Studio’s $125 invoice. Its balance became $100, and the total outstanding fell to $150. Lastly, I rewound Agent A:

StateAgent A’s worktreeAgent B’s worktree
After both implementationsamount_paid column
$175 outstanding
payments table
$175 outstanding
After recording $25 in Agent BUnchanged
$175 outstanding
Payment recorded
$150 outstanding
After rewinding Agent AOriginal code, schema, and rows
$175 outstanding
Payment intact
$150 outstanding

Agent A’s source files, schema, rows, and app output matched its saved starting state exactly. Agent B’s code, schema, rows, recorded payment, and app output stayed unchanged, as did the original seed and Git state. Giving each worktree its own database lets you compare experiments and rewind one without erasing the other’s progress.

Alternatives considered

There are a few other ways one might think to solve this problem. For example, you could save a database dump alongside your code and version it with Git, probably using Git LFS for larger dumps. These snapshots could live in a private local Git repository, separate from the code you share with collaborators.

To test this, I implemented a Git backend inside Forklift with the same database lifecycle, verification, and recovery. It stores changed Postgres files whole, then compresses similar versions during a separate packing step. Chunked storage reuses unchanged pieces during capture. Capture still reads and hashes the database files to find unchanged pieces. Reusing them reduces how much new data needs to be stored. The Git backend also checks and syncs stored objects, adding work as history grows.

Below are median times from three measurements of small row and source changes on an Apple M4 Mac, with PostgreSQL durability enabled:

Database payloadCapture with chunked storageCapture with GitFirst restore with chunked storageFirst restore with Git
8 MiB1.03 s1.68 s5.47 s5.70 s
64 MiB1.55 s3.84 s5.99 s6.08 s
256 MiB3.36 s11.19 s7.89 s8.33 s

I kept chunked storage as the default because captures were faster at every tested size. Restore times and packed history sizes were similar. That said, these results are limited to my implementation, data, and hardware. It's certainly possible that you could optimize the Git backend further to close the gap.

Current limitations

There are still boundaries to what Forklift can rewind and where it can run:

Final thoughts

This was a really fun project that stretched my brain in new ways. Although I love working with infrastructure companies, most of my own projects sit higher up the stack. Building Forklift pushed me to slow down and spend more time thinking, diagramming, and reading code so I could delegate work to agents effectively. With something more familiar, like a website, it's easier for me to be more hands-off.

Finally, a database is just one of the many systems an application connects to with state that lives beyond the code. An agent might also charge a customer's card, email a receipt, or grant someone admin access in another service, but restoring your local state won't reverse any of those actions. There's a lot more work to do to make it easy to experiment, start over, and try different approaches as agents take on more of the development process. If you're working on something related, whether it's a hobby project like mine or something more, I'd love to hear from you.