State, Checkpoints & Resumability
An agent that cannot survive a crash mid-task is a demo, not a system.
What you will be able to do
- Persist every run to SQLite: one row per step, holding the assistant turn and the tool results exactly as they went over the wire
- Kill the process mid-run and resume it with a single API call, spending nothing on steps already done
- Compact a long history without losing the decisions in it
lesson-83. New files: dayflow/store.py, dayflow/compact.py. Diff the loop: git diff lesson-82 lesson-83 -- dayflow/loop.py.What lesson 82 left on the floor
The model created two tasks in the live run. Then the process exited and they were gone, because FixtureTasks kept them in a Python list. Worse: if the process had died between step 2 and step 3, the two API calls already paid for would have bought nothing. Run it again and you pay again, and the model might create the tasks again with different keys.
Three things need to outlive the process: what the agent did (the tasks),how far it got (the steps), and what it was told (the conversation so far). One SQLite file holds all three.
State is the message history, so store the message history
Resist the urge to design a clever state object. The thing you need to resume an agent is the exact messages list the next API call would receive. That list is built from one user message plus, per step, an assistant turn and a user turn of tool results. So the steps table stores exactly those two things per step:
CREATE TABLE steps ( run_id TEXT NOT NULL REFERENCES runs(id), n INTEGER NOT NULL, -- 1-based step index stop_reason TEXT NOT NULL, assistant TEXT NOT NULL, -- JSON: the assistant content blocks, tool_use ids and all tool_results TEXT, -- JSON: the tool_result blocks we sent back (null on end_turn) input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (run_id, n) );
And the rebuild is a loop over the rows:
def rebuild_messages(self, run_id: str) -> list[dict]:
run = self.get_run(run_id)
messages = [{"role": "user", "content": run["user_msg"]}]
for s in self.steps(run_id):
messages.append({"role": "assistant", "content": s.assistant})
if s.tool_results is not None:
messages.append({"role": "user", "content": s.tool_results})
return messagesA
tool_result must carry the tool_use_id of the call it answers, or the API rejects the request. Storing the assistant blocks verbatim is what keeps those ids intact across a restart. There is a test that asserts the rebuilt history is byte-for-byte what the loop sent on the original run.Checkpoint before you spend
The loop changes in one place. After the tool results are assembled and before the next API call, the step is committed. The commit includes the run's token totals in the same transaction, so a crash can never leave the totals disagreeing with the steps.
# Tool results always go back in a user turn, one tool_result per tool_use, ids matched.
messages.append({"role": "user", "content": results})
# Checkpoint: the step is durable before the next API call can spend anything.
if store and run_id:
store.append_step(run_id, StepRow(step, "tool_use", content, results,
resp.usage.input_tokens, resp.usage.output_tokens))
if crash_after_step is not None and step >= crash_after_step:
raise SystemExit(f"simulated crash after step {step}") # test hookThe order is the point. Commit, then call. If the call fails, the committed step is still there and resume starts from it. If you called first and committed after, a crash in between would lose a paid step.
SDK objects are not JSON
The SDK returns content blocks as objects. Before they go into history or the database they become plain dicts (_plain() in the loop). The API accepts the dicts back verbatim, which is what makes the stored row and the live message identical.
Resume: one call, not three
def resume(run_id, *, store, client=None, ...):
run = store.get_run(run_id)
if run["status"] == "done": # already finished: free
return RunResult(run["final_text"], ...)
done = store.steps(run_id)
messages = store.rebuild_messages(run_id)
return _drive(messages, start_step=len(done) + 1,
used_in=run["input_tokens"], used_out=run["output_tokens"], ...)Resume hands _drive() the rebuilt history, the next step number, and the tokens already spent, so the budget guard still counts the whole run. The test for this is the one worth reading: it crashes a fake run after step 2, asserts the third call never happened, resumes with a fresh client, and asserts exactly one call was made.
The live run on lesson-83
$ dayflow brief -v --crash-after 2
[step 1] stop=tool_use
-> read_inbox({'limit': 20})
-> read_calendar({'range': 'today'})
-> read_calendar({'range': 'week'})
-> read_tasks({'include_done': False})
[step 2] stop=tool_use
-> create_task({'title': 'Send edits on partnership one-pager to Sam', ...})
-> create_task({'title': 'Return signed field trip permission form', ...})
simulated crash after step 2
$ dayflow runs
4791aaa78c73 running 2026-11-05T02:29:29 steps=2 tokens=3958/715
$ dayflow resume -v
[resume 4791aaa78c73] replaying 2 committed step(s), 3958 in / 715 out already spent
[step 3] stop=end_turn
## Today
- 13:00โ13:30 UTC: Weekly staff sync
...
## One thing
**Review Priya's vendor comparison and confirm.**
[resumed 4791aaa78c73 ยท finished at step 3 ยท 7364 in / 1297 out total]
$ dayflow runs
4791aaa78c73 done 2026-11-05T02:29:29 steps=3 tokens=7364/1297- Same run id across two processes. That is the definition of resumable.
- Step 3 cost 3,406 input tokens, the full history. Resume does not make the third call cheaper; it makes the first two free.
- The two tasks were still there when the second process started, and listing them from a third process showed the same ids.
idempotency_keyis now aUNIQUEcolumn, so the guarantee from lesson 82 is enforced by the database, not by a dict. - Reads were not re-run. The inbox was not fetched again on resume. In lesson 87 that matters: a resume should not re-hit Google's API for data already in the history.
Compaction: when the history is the problem
Every step re-sends everything. A 3-step brief is fine. A 40-step agent working through an inbox is not: by step 30 each call carries 29 steps of tool payloads, most of which the model no longer needs verbatim. Compaction replaces old tool exchanges with a summary and keeps the recent ones intact.
# Layout in: [user task] [a1][u1] [a2][u2] ... [aN][uN]
# Layout out: [user task] [user: summary of a1..a(N-keep)] [recent pairs, untouched]
def compact(messages, *, keep_last=1, threshold_tokens=40_000):
if estimate_tokens(messages) < threshold_tokens:
return messages
task, rest = messages[0], messages[1:]
pairs = [(rest[i], rest[i + 1]) for i in range(0, len(rest) - 1, 2)]
old, recent = pairs[:-keep_last], pairs[-keep_last:]
summary = "Earlier in this run (compacted):\n" + "\n".join(_summarise_exchange(a, u) for a, u in old)
return [task, {"role": "user", "content": [{"type": "text", "text": summary}]}, *sum(recent, ())]Two rules make this safe, and both are tested:
- Never touch the task or the last pair. The first user message is the job; the last assistant/user pair is the model's own most recent move, which it needs intact to continue coherently.
- The summary is a user turn, labelled. The model reads "Earlier in this run (compacted)" as context someone gave it, not as something it said.
Dayflow's summariser is deterministic: it keeps the shape of each old exchange (which tool, what arguments, ok or which error code, how many items) and drops the payload. No model call, so it is free and testable. A model-written summary keeps more meaning and costs a call; lesson 85 uses one when subagents hand results back.
Anything that must survive many compactions belongs in a file or the database and gets referenced, not carried in the history. The tasks table is the first example: the agent does not need to remember it created a task, it can read the table.
kill -9 in the middle of a brief
Watching a resume work is more convincing than reading about it. Every step here is against the real API; budget about ten cents.
- Run `pytest`. 24 tests. Read tests/test_store.py::test_crash_then_resume_spends_only_the_remaining_steps before running anything live; it is the lesson in 30 lines.
- Run `dayflow brief -v --crash-after 2`, then `dayflow runs`. One run, status running, 2 steps. Open dayflow.db with `sqlite3 dayflow.db "select n, stop_reason, length(assistant), length(tool_results) from steps"` and look at what a step actually holds.
- Run `dayflow resume -v`. Note the replay line, the single [step 3], and that the run id matches. Run `dayflow runs` again: done, 3 steps.
- Now a real crash: run `dayflow brief -v` and press Ctrl-C the moment you see step 2 start. `dayflow runs` shows how far it got. Resume. If you were fast enough to interrupt during the API call, step 2 was never committed and resume redoes it; that is correct, the call never completed.
- Delete the two created tasks' rows (`sqlite3 dayflow.db "delete from tasks where idempotency_key is not null"`), then resume the finished run. Nothing happens and nothing is spent: a done run is a lookup. That is the difference between replaying a log and re-executing it.
- In dayflow/loop.py, move the `store.append_step(...)` call ABOVE the `dispatch()` loop. Run the crash test in pytest. Read the failure, then explain to yourself why committing before the tools run would be wrong even though it "checkpoints earlier". Revert.
- TypeScript mirror: `cd ts && npm run brief -- -v --crash-after 2 && npm run resume -- -v`. Same behaviour on node:sqlite. Diff ts/src/store.ts against dayflow/store.py: the schema is identical, which is the point of storing messages rather than a bespoke state shape.
What this lesson deliberately does not do
- Wire compaction into the loop. Briefs are 3 steps; it would never fire. It is a tested primitive waiting for lesson 85.
- Run two processes on one database at once. WAL mode makes that safe for reads, but Dayflow has one writer by design until lesson 93 moves it to Postgres on a server.
- Resume automatically. You type
dayflow resume. Lesson 91 decides when a run should be resumed without a human, and lesson 86 decides when it should not.
Store the assistant turn and the tool_result turn per step, verbatim. Rebuild messages from the rows. No bespoke state object.
Assemble tool results โ commit the step (with token totals, one transaction) โ next API call. Never call before committing.
Rebuild history, start at len(steps)+1, carry the spent tokens into the budget. A done run returns its text for free.
Must survive the restart. Storing blocks verbatim is what keeps them matched.
idempotency_key is UNIQUE in SQLite. The lesson 82 guarantee is now the database's job.
Keep the task and the last pair; summarise older exchanges as a labelled user turn; deterministic, shape not payload.
Anything that must outlast compaction goes in a table or file and is referenced, not remembered.