Chapter 04 · Section II · 9 min read
4.2 Composing memory across runs without corrupting it
Writing to memory is easy. Doing it in a way that survives failed cycles, double-fires, and mixed concurrent writes is the actual engineering.
Memory that only handles the happy path is a memory that will corrupt itself the first week you leave the loop unattended. Composing memory well means designing for failed cycles, partial writes, retries, and the specific concurrency edge cases that appear once a loop runs for real.
The three failure modes that will happen
Every long-running loop’s memory will eventually face:
1. A cycle that crashed halfway through. The doer ran, the verifier accepted, but the memory-update step failed. Tomorrow’s cycle sees stale memory and thinks the work wasn’t done.
2. A cycle that ran twice. The scheduler double-fired, or a retry re-ran a cycle that had already partially completed. Memory now has two entries for what should have been one.
3. Two cycles writing at the same time. Rare in scheduled loops (they usually queue), common in event-driven loops (10 webhooks in 10 seconds = 10 concurrent cycles).
Design for these three from the start. Bolt-on fixes later are much harder.
The transaction principle
Memory writes should be transactional — they either fully succeed or fully don’t. Half-written memory is worse than no memory, because tomorrow’s cycle can’t tell it’s incomplete.
Two concrete patterns:
- Write-then-swap. Write the new memory state to a temp file (or a temp key), then atomically rename/move it into place. If the write fails halfway, the old state is untouched. This works for file-based memory.
- Database transactions. If your memory lives in SQL, wrap the update in a transaction. All updates commit together or none do.
Either way, the invariant is: at any moment, another reader sees either the pre-write state or the post-write state. Never a partial mix.
Cycle IDs are the antidote to double-runs
Give every cycle an ID derived from its inputs, not from the clock:
- Scheduled loops:
{loop_name}-{date}— e.g.,ticket-triage-2026-07-22. The same day will always generate the same ID. - Event-driven loops:
{loop_name}-{event_id}— the event’s own ID makes the cycle idempotent.
Before writing anything, the cycle checks memory for its own ID. If it’s already there, this is a re-run — skip or take the no-op path. If it’s not, proceed and write the ID as part of the cycle output.
That single pattern kills the entire “cycle ran twice” class of bug.
Compaction: keeping memory from growing forever
Append-only memory grows forever. A daily loop with 300 tickets per day, running for two years, produces ~200,000 entries. Loading all of them into every cycle is impractical.
Two compaction strategies:
- Rolling window. Keep the last N cycles verbatim; summarise older ones. Every cycle both reads (recent cycles + summary of older ones) and writes (adds itself; possibly folds the oldest into the summary).
- Time bucketing. Weekly, monthly, quarterly buckets. Each bucket has both a set of entries and a rollup summary. The cycle reads whichever depth it needs.
Compaction is not automatic. Someone (or the loop itself) has to design what a “rollup” of yesterday’s ticket triages looks like — probably counts, escalations, and any notable patterns. Rollups that are just “yesterday’s cycles all ran” contain no information.
The observer test
Every memory design should pass this test: if I open memory in an editor right now, can I answer ‘what has this loop done, and what should tomorrow’s cycle know?’ in under a minute?
Signs it doesn’t pass:
- The file is 200MB of JSON.
- Every entry is a wall of narrative text.
- Dates are missing so you can’t tell what happened when.
- Recent entries are indistinguishable from year-old entries.
Signs it does pass:
- One clear entry per cycle, with a date, an ID, and a short structured summary.
- A “current state” section at the top summarising open follow-ups and recent rollups.
- Old entries archived out to a separate file when they’re no longer needed for daily reads.
When memory disagrees with reality
Occasionally you’ll notice memory says something the real world contradicts — memory claims ticket 1204 is closed, but the ticket system shows it open. Something happened between cycles that memory doesn’t know about.
Two options:
- Reality wins. Update memory from the source of truth. The cycle re-does whatever’s needed. Safe default.
- Reconcile explicitly. A human (or a special reconciliation cycle) looks at the discrepancy and decides what to do.
Never let memory silently override reality. Every scheduled loop should have a periodic reconciliation — even if it’s just a weekly sanity-check comparing memory against the source of truth. Drift is inevitable; catching it early is the difference between “small fix” and “rebuild memory from scratch.”
Check your understanding
Quick check
—