Solve once

The task I stopped doing by hand: how db.mjs became Ashley's memory

Every agent system eventually runs into the same wall: something has to remember what happened. Not "remember" in the vague, hand-wavy sense — remember in the precise sense of this task exists, it is in this state, and the next agent that looks at it needs to see the same thing I just wrote, even if that agent is a different process, a different session, or running an hour later on a different machine.

I hit that wall building Ashley, my always-on work-manager agent. And the way I got out of it is a clean example of the pattern I keep coming back to: solve the problem once, turn the solution into an agent (or in this case, a script), and never solve it by hand again.

The problem, stated plainly

Ashley dispatches work — background tasks, scheduled runs, remote cloud jobs — and needs a fleet view: what's open, what's done, what's stuck. That state has to survive across sessions. The obvious answer is "put it in a database." The less obvious part is how you write to that database without ever making an agent's response latency depend on a network round-trip.

If every task update has to wait on a Postgres write before the agent can respond, you've traded a coordination problem for a latency problem. Ashley's whole value proposition is responsiveness — dispatch immediately, never block the user. A synchronous DB write on the hot path violates that on day one.

The solve-once answer: local-first, write-through

The script that owns this is db.mjs — the single writer for Ashley's fleet store. It's built on three rules that, once written down, apply to every future write path without re-deriving them:

  1. Synchronous local WAL. Every set appends a full merged row snapshot to a per-session, append-only WAL file and returns immediately. No network on the hot path. This is the same trick a fast local-first system always uses: the disk write is cheap and instant, and it's the only thing the caller waits on.
  2. Asynchronous, batched replication. A separate flush command replicates every dirty WAL row to Supabase in a single transaction — one batched upsert, not one round-trip per field. It runs off the intake path, triggered on completion wakes and yields, never inline with a response.
  3. Offline fallback, fail-open. If the database URL is unset or the pooler is unreachable, every hot-path command still succeeds against the WAL. Nothing is lost — the row just replicates on the next successful connect. A dead session orphans nothing, because any session (or a console reading straight from Supabase) can reconcile the full fleet on demand.

None of these three rules is exotic on its own. What matters is that they're captured once, in one script, instead of being re-decided every time a new part of the system needs to persist something.

The part that actually took thought: IDs without blocking

Task IDs need to be globally unique — the one property a pure local write can't guarantee by itself. The naive fix is a round-trip to a database sequence on every allocation. That reintroduces exactly the latency I was trying to eliminate.

The fix: pre-allocate a small block of IDs in a single round-trip, using the database's own counter function, and hand them out locally until the block runs dry. Because the block is reserved atomically at allocation time, two sessions running Ashley at once never collide — even though neither one is talking to the database on the common path. The network only gets touched when a block empties out, and even then, a provisional ID covers the gap if the database happens to be unreachable at that exact moment.

That's the pattern in miniature: find the one place where the easy synchronous version would leak into user-facing latency, and design around it, once, rather than accepting the tax forever.

What "solve once" actually bought me

The payoff isn't that db.mjs is clever. It's that I have not had to think about this problem again. Every new command that needs to persist state — a personal task, a durable artifact registration, a priority list — reads and writes through the same WAL-first, flush-batched, fail-open contract. I didn't re-derive the local-first pattern for personal tasks. I didn't re-derive it for artifacts. I wrote the rules down once, in one file, and every subsequent feature inherited them for free.

That's the real shape of recursive automation: it's not that any single task gets automated — it's that the pattern for solving that class of problem gets automated, so the next ten instances of the same shape cost nothing. Climb the abstraction ladder once, and you stop paying the toll at every future intersection.

I'll keep writing these up as they happen — not as a highlight reel, but as the actual mechanics, because the mechanics are the only part worth copying.