A reply that never arrived
A generation request can be large. The architecture's own example: two 15-frame collections, two providers and five candidates each come to 300 tasks across two requests of 150, one per set. Now picture the reply getting lost. A phone on a slow uplink, a proxy that times out, a harness that retries a brain's tool call. The client cannot tell "never arrived" from "arrived, and the answer was lost". If retrying creates a second batch, every hiccup in the network becomes a second bill.
The caller names the request
Every generation carries a request_id of 1 to 100 characters, chosen by the caller. The server never invents one, because only the caller knows whether two posts are the same intent. The web studio mints it with crypto.randomUUID() when Dad opens the review step, keeps the whole request body frozen in that preview, and the submit button sends the frozen request. If the reply is lost, the preview is still open, and pressing submit again sends the same id.
A fingerprint beside the id
An id alone is not enough. A client bug could reuse an id for a different request, and the server would hand back the wrong batch with a straight face. So the requests table stores a fingerprint next to each id: the SHA-256 of the payload, serialised as JSON with sorted keys so the order of fields cannot change the hash. Three outcomes follow.
- A new id: plan the batch and create it.
- A known id, same fingerprint: return the record it created the first time. No new plan, no new tasks.
- A known id, different fingerprint: 409,
The request ID already belongs to different input.
The remix test drives all three. It submits a request for two providers, three candidates each, over a two-frame set, and counts 12 tasks. It submits the same request again and gets the same batch id back. Then it changes only the prompt under the same id and expects Conflict.
The whole tree in one transaction
A batch is a parent record with one child task per image. create_intent writes all of it at once, and its docstring says so in one line: commit the idempotency key, intent and complete task tree together. It opens with BEGIN IMMEDIATE, checks the request id inside that write transaction, inserts the batch, inserts every task with the batch as its parent, inserts the request binding, and commits once.
Consider the alternatives. Commit the batch, then the tasks: a crash in between leaves a batch with half its tasks, and the worker would run half a plan. Commit the tasks, then the binding: a crash leaves a complete batch the retry cannot find, so the retry builds a second one. A single transaction closes both gaps. A plan may hold up to 600 tasks by default (AVATARS_MAX_BATCH_TASKS), and they all commit together or not at all.
BEGIN IMMEDIATE takes the write lock before the check. Two threads with the same request id cannot both see "not yet" and both insert. The second waits for the lock, then finds the first one's binding and returns its batch.
Proving the rollback
One test is worth reading line by line. It asks create_intent for a batch with two tasks, and the second task's body holds a bare Python object(), which json.dumps cannot serialise. By the time that TypeError fires, the batch row and the first task have already been inserted. The test then asserts that there are no batches, no tasks, and no binding for the request id. A retry with a good payload starts from nothing.
Network outside, transaction inside
Planning a batch talks to Pippa, who decides which subjects a caller may see, and to Ember, which knows the generators and resolves the automatic route. None of that may happen inside a SQLite transaction. So generate works in three steps under the studio's intent_lock, an asyncio lock: check the request id with a plain read, build the plan over the network, then call create_intent, which checks again inside its own transaction before writing. The early check spares a retry a second round of planning. The late check is the one that guarantees.
The same question, elsewhere in the studio
- Publication goes through
create_intentas well, so a publish whose reply was lost resumes the same publication. Publishing Is One Way tells that story. - Photo uploads can carry a
client_key. Saving an upload writes a file first, and a file cannot join a SQLite transaction, so this path uses the store's two smaller calls: look up the request, save the asset, then bind the request to it, all under an upload lock. The Studio in Your Pocket covers why the phone needs it. - The worker asks the question of its own steps. Before it fetches a finished image from Ember,
finishlooks for an asset that already carries this task's id and reuses it. A worker that died after saving the image does not save it twice.