2.4. Sessions
In one glance
- You will: Start the persistent A2A server, read a live task and its owning session out of SQLite, and leave an incomplete restore journal behind to watch startup refuse to guess.
- You need: 2.1. First Agent finished, with Ollama still serving: readiness checks the named-entity model catalog. Only submitting a task spends inference.
- Time: about 20 minutes, hands-on.
What a session is, and what it must survive
A session is the conversation state for one application, one user, and one session identifier. It holds the ordered events of every turn so far, plus the application state carried alongside them. The reference agent keeps its sessions in SQLite through ADK’s database session service.
Sessions live on disk rather than in memory, because the process is the least durable thing in the system: deploys roll, containers restart, laptops sleep. A conversation held only in a running process dies with it, and whoever picks the work back up starts from nothing while the system is still broken. Resume an interrupted investigation of INC-002, and what has already been ruled out is a question only the stored events answer. That is the difference between “the process remembers” and “the conversation exists”.
This page proves it: two SQLite files, one identifier joining them, and a startup that refuses to run on state it cannot account for.
Start the deployed surface:
cd agents/go
mise run a2aThat blocks and serves on :8080. From a second terminal, ask it two different questions:
curl -s http://127.0.0.1:8080/livez
curl -s http://127.0.0.1:8080/healthz{"status":"alive"}
{"status":"ready"}The two answers look alike and mean different things. Liveness answers is this process still looping, and a restart fixes a failure there. Readiness answers can this process serve a turn: it opens both state databases and checks their schemas, and confirms the state directory still accepts writes. The PII redaction guardrail is on by default, so readiness also confirms the named-entity model is in the model catalog, which is why /healthz goes unready when Ollama stops. AGENT_PII_MODEL defaults to the same qwen3:4b-instruct the agent answers with, so that check costs no second download.
A generation is one complete published copy of the state databases. Readiness never migrates or publishes state, because a probe that changed the generation on disk would change the thing it is measuring. Readiness writes exactly one thing, a .readiness-* file it creates and immediately unlinks, because “can I still write here” is not a question you can answer by reading.
How an A2A task differs from the session that owns it
Submit work over A2A and a second object appears. An A2A task is protocol-visible work with its own id, a context id, a status, messages, artifacts, and a cancellation lifecycle — what a remote caller polls, cancels, and resumes. The session is the conversation; the task is one unit of work inside it.
They live in different databases, and you can watch them line up. A2A rides on JSON-RPC: every call posts to the same endpoint and names its method in the body rather than the path. message/send submits a turn:
curl -s -X POST http://127.0.0.1:8080/ -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"What is the status of INC-002?"}],"messageId":"m-1","kind":"message"}}}'That post is the only command here that spends inference, so it costs a full model turn: seconds on a GPU, minutes on a CPU-only laptop. Once it returns, read the task store directly, from the repository root:
sqlite3 -header -column agents/go/.state/tasks.db \
"select id, context_id, state from tasks order by last_updated_ns desc limit 2;" id context_id state
------------------------------------ ------------------------------------ ------------------
019fecf4-8b2d-78d9-8fe6-72618f58aa56 019fecf4-8b2d-79bd-a466-02db764bc2a0 TASK_STATE_WORKING
019fecf3-7a1e-7a2a-8266-f1b5261da423 019fecf3-7a1e-7ac4-9d6b-b9bef64a04f5 TASK_STATE_WORKINGThose context_id values are not task ids. Ask the other database what they are:
sqlite3 -header -column agents/go/.state/runtime.db \
"select id, app_name from sessions order by rowid desc limit 2;" id app_name
------------------------------------ --------------
019fecf4-8b2d-79bd-a466-02db764bc2a0 agentops-agent
019fecf3-7a1e-7ac4-9d6b-b9bef64a04f5 agentops-agentThere is the join: each task’s context id is the id of the ADK session that owns it. Two databases, two vocabularies, one identifier carried across both. And a task that is still thinking is already durable — TASK_STATE_WORKING is on disk, not in a goroutine’s memory, so a client that reconnects after a restart can still ask what happened to it.
Four files end up in agents/go/.state/ once you have run a turn: runtime.db holds ADK’s sessions and events, tasks.db holds the A2A task store, incidents.db is the writable copy of the committed seed, and vectors.db is the derived retrieval index. A fifth, memory.db, joins them the first time the agent saves a note. All of them are disposable.
Writes are serialized by three mechanisms, each closing a different race:
- The session pool holds one connection, so two handles cannot race into an intermittent “database is locked”.
- The task store opens every write with
BEGIN IMMEDIATE, taking the write lock up front rather than at the first mutation, so nothing slips between a read and its write. - A per-session lock keeps two turns in the same conversation from interleaving their event appends, while unrelated sessions run concurrently.
SQLite is the right choice for one replica and the wrong one for several: a second replica writing the same file needs a shared database, not a bigger disk. That is a setting rather than a rewrite: 6.9. Scale Out moves these sessions onto PostgreSQL, proves it with one conversation across two processes, and names the state that deliberately stays here.
How the restore journal survives a crash mid-restore
A restore replaces the live state databases wholesale, and a process can die mid-replacement. kill -9 a restore halfway through, restart, and the state directory comes back on exactly one generation: the complete old one or the complete new one, never a mixture.
Every restore runs under a process lock and walks a three-phase journal: prepared while the new generation is staged and the old one is being moved aside, rolling_back if anything goes wrong, committed once the new generation is live. The journal is fsynced before the first rename, so whatever the next start finds is enough to decide what to finish. The old generation is quarantined rather than deleted, which is what leaves a rollback byte-exact. Recovery, the first thing startup runs, rolls a committed transaction forward and anything else back to that old generation, verified by SHA-256 rather than by filename. Residue it cannot account for is a hard error.
The suite kills a real process at real seams: each scenario re-executes the test binary, replaces the rename call with one that calls os.Exit at a chosen point, then asserts what the surviving parent rebuilds from what the dead child left behind:
cd agents/go
go test ./state -run 'TestRestoreRecoversByteIdentical|TestRestorePreservesCommitted' -v -count=1=== RUN TestRestoreRecoversByteIdenticalOldGenerationAfterProcessExitOnFirstReplacement
crash_test.go:242: after-quarantine child exited 71:
--- PASS: TestRestoreRecoversByteIdenticalOldGenerationAfterProcessExitOnFirstReplacement (0.49s)
=== RUN TestRestorePreservesCommittedNewGenerationAfterProcessExitBeforeCleanup
crash_test.go:264: before-cleanup child exited 72:
--- PASS: TestRestorePreservesCommittedNewGenerationAfterProcessExitBeforeCleanup (0.38s)
PASS
ok github.com/MLOps-Courses/agentops-open-course/agents/go/state 0.878sExit code 71 is a process that died immediately after the old generation’s first file was moved aside; 72 is one that died after committing but before cleaning up. Both parents recovered. The dot-prefixed .restore-* files you may find in .state/ afterwards — the journal, its write-ahead temporary, a staging directory, a quarantine directory — are that mechanism’s crash notes. Deleting them by hand is the one reliable way to break a guarantee these tests otherwise keep, because it removes the only record of which generation was real.
For everyday resets, stop every writer, then throw the runtime state away:
cd agents/go
mise run data:resetNone of this needs a model. The whole persistence story — schemas, lifecycle, restore, recovery — is decidable offline with cd agents/go && go test ./a2aserver ./state ./cmd/agent.
Your turn: plant an incomplete restore journal and start the server
Predict before you run this: you are about to leave a syntactically valid but incomplete restore journal in the state directory and start the server. Does it start and log a warning, start and ignore the file, or refuse to start at all — and which would you want from a process that cannot tell which copy of your state is real?
- Mode:
temporary experiment. - Goal: watch an unexplained restore journal fail closed, then confirm that removing it lets startup proceed.
- Files to touch: only
agents/go/.state/.restore-journal.json, which you create and then delete. The committed seed underagents/data/is never touched. - Preflight: stop the A2A server with
Ctrl-C, then confirm no journal is already present withtest ! -e agents/go/.state/.restore-journal.json. - Steps: from
agents/go, write an incomplete journal withprintf '{"phase":"prepared"}' > .state/.restore-journal.json, then runmise run a2aand read the failure. - Gate that proves completion:
mise run a2aexits non-zero withRestore journal is incomplete or has unsupported fields., and starts normally again once the file is gone. - Final state: run
rm -- .state/.restore-journal.jsonfromagents/go; the server then starts andcurl -s http://127.0.0.1:8080/healthzreports ready.
The refusal reads in full:
[a2a] $ go run ./cmd/agent a2a
agent: recovering an interrupted state restore: state snapshot failed: Restore journal is incomplete or has unsupported fields.
exit status 1
[a2a] ERROR task failedStartup order is what makes that refusal possible:
func (s *Server) Start(ctx context.Context) error {
if err := s.recoverState(ctx); err != nil {
return fmt.Errorf("recovering an interrupted state restore: %w", err)
}
// Recovery treats a missing state directory as a clean first boot. Only
// after that decision may startup create the directory it will publish into.
if err := os.MkdirAll(s.options.StateDir, stateDirPerm); err != nil {
return fmt.Errorf("creating the state directory %s: %w", s.options.StateDir, err)
}
if err := preflightStateStores(ctx, s.options.StateDir, s.options.SessionBackend); err != nil {
return fmt.Errorf("checking runtime state before startup: %w", err)
}
if err := s.prepareDataset(ctx); err != nil {
return fmt.Errorf("preparing the runtime dataset: %w", err)
}
if err := s.migrateSessions(ctx); err != nil {
return fmt.Errorf("preparing the session store: %w", err)
}
tasks, err := OpenTaskStore(ctx, TaskStoreConfig{
Path: filepath.Join(s.options.StateDir, TaskDatabaseName),
})
if err != nil {
return fmt.Errorf("preparing the task store: %w", err)
}
s.tasks = tasks
handler, err := s.buildHandler()
if err != nil {
return errors.Join(fmt.Errorf("building the A2A surface: %w", err), s.Close())
}
s.handler = handler
return nil
}Recovery is step one, before the state directory even exists, because a half-published generation is indistinguishable from a healthy one until it has run. Only then does startup publish the writable dataset, prepare the session and task schemas, and build the HTTP surface — the first moment a request could be served at all. Moving the recovery later would not be a refactor; it would be a process that cheerfully serves a conversation reconstructed from two different generations.
What you can do now
- You can start the persistent A2A server the Kubernetes chapters deploy, and say why
/healthzgoes unready when Ollama stops while/livezstays alive. - You can name what joins
tasks.dbtoruntime.db: a task’scontext_idis its owning session’s id. - You can predict what an unexplained restore journal does: startup exits non-zero rather than warn and serve.
- You can settle the whole persistence story offline with
cd agents/go && go test ./a2aserver ./state ./cmd/agent.
Three files now hold what “the agent remembers” means: one for the conversation, one for the work, and a journal that decides which copy of both is real after a crash. Which of the three survives a second replica is the question 6.9. Scale Out answers.
Continue to 2.5. Dev Loop, where you stop starting servers by hand and pick the cheapest command that answers the question you actually have.