Agent reliability

Retry an agent write without doing the work twice

Idempotency in TopDo is opt-in, keyed on four fields, and it expires after 48 hours.

A timeout tells your agent nothing. The write may have landed and the response may have been lost, or nothing may have happened at all. TopDo gives you a way to retry that ambiguous request without performing the action twice — but it is not on by default, it is keyed on more than the key you send, and it stops protecting you after 48 hours. This is the exact contract, including the part it deliberately refuses to fix for you.

An engineer pausing at her laptop to check a job's result on her phone
A retry repeats an intent.

Nothing is deduplicated until you send the header

Most readers assume the opposite, so start here. Over REST, TopDo deduplicates a request only when an Idempotency-Key header is present and the method is POST, PATCH, PUT or DELETE. Every other request runs straight through. A GET is never deduplicated, and neither is a POST that arrives without the header — it is simply executed, twice if you send it twice.

That is a deliberate opt-in, and it means the protection is a property of your client, not of the server. If your HTTP layer strips unknown headers on retry, or your retry path is a different code path from your first attempt and only one of them sets the header, you have no idempotency at all and nothing will tell you. The key itself is capped at 200 characters; longer and the request is refused with 422 invalid_key before any work is attempted.

Decide the key before you send, not after you fail. Generate it when the agent forms the intention to act, persist it beside whatever state the agent is resuming from, and reuse it for every retry of that same intention. A key generated inside the retry loop is a new key on every attempt, which is the same as sending none.

Request identity is a tuple, not a key

The stored record is keyed on four columns together: workspace_id, principal_id, method_path and key, where method_path is the literal string "<METHOD> <path>" of the request. The raw request body is hashed with SHA-256 into body_hash and stored alongside. Nothing is matched on the key alone.

principal_id being part of that tuple has a consequence worth stating plainly: the same key sent by two different agent keys produces two different records, and therefore two separate executions. If you run a fleet of workers under one agent principal they share the deduplication; if each worker holds its own key, they do not. The same is true of a human session retrying what an agent started — different principal, different record, second execution.

The path is the concrete request path, so POST /api/v1/nodes/nd_7Q2/complete and POST /api/v1/nodes/nd_A11/complete are different identities even under one key. That is usually what you want. It also means you cannot reuse a key across two endpoints and expect the second to be rejected.

First requestsame keyRetry after a timeoutsame keyOne recorded writethe second is replayedSame responseno duplicate work
The replay is the stored bytes.

Read the two refusals as different facts

When a record already exists for the tuple, TopDo compares your body hash against the stored one. A mismatch is 409 idempotency_conflict, "Idempotency-Key was reused with different content". That is not a transient error and retrying it will never clear it. It means your agent reused a key for a genuinely different action, and the correct response is to work out which of the two actions you actually intend and give the other one its own key.

The second refusal is different. If a record exists but no response has been stored yet, you get 409 with either idempotency_in_progress or idempotency_committed and the message "This operation has already started". Another attempt is in flight, or it got far enough to write to the database and has not finished returning. Here retrying later is reasonable — the record will either carry a stored response or be gone.

When a completed record does match, nothing is re-executed. The stored response body is returned verbatim, with the original status code and the original Content-Type. A replayed 201 is still a 201. Do not write client logic that treats a replay as distinguishable from the first response, because it is not meant to be.

A write that reached the database is never silently retryable

This is the subtle part, and it is the reason the contract is worth trusting. When the handler returns 5xx or 429, the reservation row is deleted so you can try again with the same key — but the delete carries AND committed=false. A record marked committed survives the failure, and your next attempt with that key gets idempotency_committed rather than a clean re-run.

committed is set inside the transaction that writes the audit row for the change, in the same statement path that charges the operation against your plan. So it flips at exactly the moment the write reached the database, and if that transaction rolls back the flag rolls back with it. There is no window in which the row says committed but the change did not happen.

The failure this prevents is the expensive one. An operation commits, the connection then drops or the process dies before the response is stored, and the client sees a 5xx. Without the guard, deleting the reservation would invite a retry that executes the same write a second time — a second comment, a second node, a second close. With it, the retry is refused, loudly, and your agent is forced to read the current state instead of guessing.

Over MCP the key is required and the bytes stop mattering

The MCP surface uses the same agent key and the same idempotency_keys table, but three rules differ. Every mutating tool requires an idempotency_key argument of 1 to 200 characters — tools/list injects it into the JSON Schema of each mutating tool as a required property, and a call without it is refused with JSON-RPC -32602. Read tools are not deduplicated at all.

Second, method_path is "MCP <tool_name>" rather than a URL, so the node id lives inside the hashed arguments instead of in the identity string. Third, and most useful in practice, the hash is taken over a canonical JSON serialization of the arguments — keys sorted recursively, non-ASCII escaped — rather than over raw bytes. Reordering the fields of your argument object over MCP still matches the stored record. Reordering the fields of a JSON body over HTTP does not: those are different bytes, so a different body_hash, so 409 idempotency_conflict.

The same retry, sent two ways
POST /api/v1/nodes/nd_7Q2/complete
Idempotency-Key: complete-nd_7Q2-attempt-1
{"claim_token": "…", "expected_version": 12}

{"method": "tools/call", "params": {"name": "complete_work",
  "arguments": {"node_id": "nd_7Q2", "claim_token": "…",
    "expected_version": 12,
    "idempotency_key": "complete-nd_7Q2-attempt-1"}}}

The MCP conflict comes back as JSON-RPC -32001, "Idempotency key was reused with different arguments", with data.code carrying the same idempotency_conflict string you would see over REST. Handle both shapes if your agent speaks both protocols.

What the store redacts, and what it forgets

Two responses are not stored as they were sent. A claim_work response is encrypted before it is written and decrypted on replay, because it carries the plaintext claim_token — the only copy you will ever get, since only its SHA-256 hash is kept on the node. Replaying a claim therefore gives you back a usable token rather than a hole. Responses from agent key issuance and rotation have their key field nulled before storage, so a replay of those returns the envelope without the secret.

Retention is 48 hours, written into expires_at at insert and swept hourly by the retention job. After that the row is gone, and a request carrying the same key is not a retry any more — it is a new action, and it will execute. If your agent can be paused, queued or manually resumed across more than two days, the idempotency key it kept is no longer protecting anything, and you need to re-read the workspace before acting on it.

Say that limit out loud in your runbook. Forty-eight hours is generous for a retry loop and short for an incident that takes a weekend to resolve.

Idempotency cannot repair a stale completion

Here is where the mechanism stops. complete_work requires expected_version, and a body without it is rejected by the request schema before the handler runs: 422 invalid, "Invalid request", with the offending field named in the issues detail. If the number you send does not equal the node's current version you get 409 version_conflict, "The document changed; read the current version", with expected_version and current_version both in the detail so you can see how far behind you are.

version is incremented by a database trigger on every update of the row — not only on edits to the title or body, which advance content_version separately. A label change, a move, a state change, an assignee change, even a heartbeat re-leasing your own claim: each one raises version. So the number you read at claim time is stale the moment anything at all touches that node.

Replaying a stale completion cannot make it correct. The retry contract preserves your intent; it does not preserve the world your intent was formed in.

The same is true of 409 changes_pending, "Review changed requirements before completing this work", raised when the node's pending_changes map is not empty. No amount of retrying with the same idempotency key will clear it, because nothing about the stored request has changed. The agent has to re-read the node, call acknowledge_changes with the exact observed map, and then decide whether the completion it was about to send still describes what it did.

One writer per workspace decides what a conflict means

Every mutation in TopDo takes a transaction-scoped Postgres advisory lock keyed on the workspace id, and the audit write takes it again. Two mutations in the same workspace do not interleave: the second waits for the first to commit or roll back. The lock lives in withOrg, which read uses as well, so the ceiling is one request per workspace rather than one write — a read waits behind a slow mutation. That is the shape the rest of the contract is built on.

This is why a version conflict is always a real conflict rather than an artifact of a race. There is no window in which two writers both read version 12 and both succeed. One of them committed, version became 13, and the other is now holding a number that describes a node that no longer exists in that form. The right reading of 409 version_conflict is "someone else did something", not "try again harder".

It also means a retry storm costs you throughput on the whole workspace, not just on the node you are retrying. An agent that backs off on 409 is being a better citizen than one that hammers, and the Retry-After header on a 429 tells you in seconds how long to wait.

Check the contract before you depend on it

Every one of these behaviors is observable from a client. Run the checks against a workspace you can afford to dirty, and run them before the first incident rather than during it.

  1. Send the same POST twice with the same Idempotency-Key and confirm exactly one node appears, then send it twice without the header and confirm two do.
  2. Reuse one key with a changed body and confirm 409 idempotency_conflict, not a silent second write.
  3. Send the same key from two different agent keys and confirm both execute — that is the principal_id in the tuple.
  4. Over MCP, send the same arguments with the fields in a different order and confirm the call replays rather than conflicting.
  5. Claim a node, let a second write bump version, then complete with the version you read at claim time and confirm 409 version_conflict with both numbers in the detail.

The discipline that follows from all of this is narrow. Persist the key with the intent, retry the same tuple on 5xx and 429, treat idempotency_conflict and version_conflict as instructions to re-read rather than as noise, and assume no protection at all past 48 hours. TopDo does not run your agent and it does not judge its output; it records the write, tells you precisely which write it recorded, and refuses to let a lost response turn into a second one.