Skip to content

8 min read

A done flag is a claim, not a result

On this page

The research-summarisation job runs four agents against a queue of twenty-six topics, and it works most of the time. Over fourteen days it completed 340 task entries. Eleven of those were done twice by two different agents, which at roughly 85,000 tokens per summarisation task is about 935,000 tokens of duplicated model work, somewhere near thirteen dollars of nothing. Six entries were marked complete but produced no file anybody could find; the report stage skipped them silently, and the gap was noticed a week later when a reader asked why topic 14 was missing.

Both failures come from the same file. Here is an entry from it:

json
{"id": 14, "title": "Regulatory posture, EU AI Act tiering", "done": false}

That is the entire schema. A title for a human, and a boolean that four agents read and write.

What the boolean is being asked to mean

Trace the duplicate. Agent B and agent D both poll the file on a five-second interval, and their reads land close together:

text
09:00:03.114  agent-B  GET tasks.json  -> entry 14: done=false
09:00:03.140  agent-D  GET tasks.json  -> entry 14: done=false
09:00:03.148  agent-B  begins summarisation of topic 14
09:00:03.201  agent-D  begins summarisation of topic 14
09:04:51.906  agent-B  PUT tasks.json  -> entry 14: done=true
09:06:12.330  agent-D  PUT tasks.json  -> entry 14: done=true

Neither agent did anything wrong by its own rules. The file said the task was not done, and it was not done. done: false is a true statement about the world at 09:00:03 in both reads, and it stays true for the four minutes and forty-eight seconds it takes agent B to finish. During that window the file offers no way to distinguish an item nobody has touched from an item somebody is four minutes into, because the boolean has one bit and the run has three states in it: nobody has started, somebody is working, the result exists.

The second failure is the same shortage seen from the other end. When agent B writes done: true, that write asserts something about a world outside the file — that a summary of topic 14 now exists somewhere. No reader can check the assertion, because the entry does not say where the summary went. When agent B's write succeeded but its upload to object storage had failed with a 503 four seconds earlier — the HTTP status an overloaded service returns to say it did not accept the request — the entry still says done: true and there is nothing in the document that contradicts it. The report agent reads twenty-six entries, sees twenty-six trues, and produces a report from the twenty documents it can actually find.

The temptation at this point is to widen the boolean into an enum and move on:

json
{"id": 14, "title": "…", "state": "pending|in_progress|done"}

That closes the first failure narrowly and reopens it a week later. in_progress tells the next reader that somebody has the task, and nothing else. It does not say who, so a crashed agent leaves an entry that is permanently in_progress with no way to tell whether the holder is alive. It does not say since when, so there is no threshold anybody can apply. And it does not touch the second failure at all, because done is still an unverifiable assertion. The enum is tempting because it is a one-line change to the schema and the duplicate rate drops immediately, which makes it look like a fix rather than a deferral.

Two axes, not one scale

The states in this system are not points on a line. There are two independent things a reader needs: what phase the work is in, and who is responsible for it right now. They vary separately, and a single field cannot carry both.

No ownerOwner recorded
pendingAvailable to claimImpossible; a bug worth alerting on
in_progressOrphan: holder crashed after claimingNormal, and reclaimable when owned_since is old
doneNormal terminal stateAgent finished but did not release; harmless, still worth noticing
failedAvailable for retry, with the reason attachedHolder recorded the failure and is retrying

The row that pays for the whole table is in_progress with no owner. In the enum design that cell does not exist as a distinguishable state, so an orphaned task and a healthy in-flight task look identical, and any recovery logic you write has to guess. With ownership as its own field, the orphan is visible without inference.

The full entry looks like this:

json
{
  "id": 14,
  "title": "Regulatory posture, EU AI Act tiering",
  "status": "in_progress",
  "owner": "agent-B@i-0a3f",
  "owned_since": "2026-03-11T09:00:03Z",
  "lease_expires": "2026-03-11T09:10:03Z",
  "attempts": 1,
  "inputs": {
    "sources": ["s3://corpus/eu-ai-act/", "s3://corpus/reg-tracker/2026-q1/"],
    "max_words": 1200
  },
  "output": null,
  "last_error": null
}

And on completion, the fields that make the claim checkable:

json
{
  "id": 14,
  "status": "done",
  "owner": null,
  "completed_at": "2026-03-11T09:04:51Z",
  "attempts": 1,
  "output": {
    "uri": "s3://summaries/run_311/topic-14.md",
    "bytes": 7412,
    "sha256": "9f2c1d…",
    "written_at": "2026-03-11T09:04:49Z"
  }
}

owner and owned_since are what stop the duplicate: agent D, reading at 09:00:03.140, sees the task held by agent B since 09:00:03 and moves to the next available topic. lease_expires turns the claim into a lease, a hold that lapses at a stated time unless its holder renews it, which is what stops the protection from becoming a permanent block when agent B's host is terminated, since after 09:10:03 the claim is stale and the entry is available again. attempts is what keeps a poisonous task from being retried forever by successive agents each thinking it is the first. And output is the pointer that converts done from an assertion into something a reader can go and check.

Verification is a read, not a promise

The value of naming the output location is that the next agent can do this before it starts:

python
def usable(task, store):
    if task["status"] != "done":
        return False
    out = task.get("output")
    if not out or not out.get("uri"):
        return False
    meta = store.head(out["uri"])          # HEAD, not GET: ~15 ms
    if meta is None:
        return False
    return meta.size == out["bytes"]

The report agent runs that over twenty-six entries at roughly fifteen milliseconds each, so about four hundred milliseconds added to a stage that already takes ninety seconds. In exchange, the six entries that claimed completion without producing anything fail loudly at the point where they are consumed, naming the specific task and the specific URI that is missing, rather than silently shrinking the report.

The format of owner matters more than it looks. Writing agent-B identifies a role, and roles do not crash — processes do, and the process that held topic 14 on Tuesday is not the one running under the same role name today. Recording agent-B@i-0a3f, where the second half is the instance or container identifier, means a reader can ask whether that specific worker is still alive before deciding whether the claim is stale, and a recovery routine can distinguish "agent B is busy" from "the process that claimed this no longer exists". The same argument applies to including the run's attempt or deployment identifier when agents are redeployed mid-run, since a claim held by a container that was replaced during a rollout looks healthy by timestamp alone.

There is a design question hiding in that function about how much checking is worth doing. Comparing the byte count catches a failed upload and a truncated one. Comparing sha256 catches corruption too, at the cost of a full download, which for a 7 KB summary is trivial and for a 400 MB parsed corpus is not. A reasonable rule is that the consumer verifies as cheaply as the failure mode allows: existence and size for outputs it is about to read anyway, checksums for outputs it is about to trust without reading. What matters is that the entry carries enough for either check to be possible.

It is also worth writing failure into the schema rather than leaving it as the absence of success. last_error holding {"code": "upload_failed", "detail": "503 from object store", "at": "…"} is the difference between an on-call engineer knowing why topic 14 is stuck and inferring it. An entry that can only be done or not done forces every failure to be represented as a task that has been pending for an unusually long time, which is the least informative encoding available.

What a better schema does not buy you

Ownership fields make a claim visible; they do not make claiming atomic, meaning performed as one indivisible step that no other agent can slip inside. Agent B and agent D can both read the entry with owner: null and both write themselves in, and if the writes are whole-document replacements the second one wins and the first one carries on working on a task it no longer holds. The schema in this chapter narrows the collision window from four minutes and forty-eight seconds — the duration of the work — down to the few milliseconds between read and write, which cuts the observed duplicate rate substantially without removing the race, the term for two agents both acting on a view of the file that the other is in the middle of invalidating. Eleven duplicates in 340 tasks becomes something closer to one, and one is still not zero. Closing it properly needs the writes themselves to be conditional, which is the next chapter.

The schema also says nothing about whether the output is any good. output.uri existing with 7,412 bytes behind it means an agent wrote a file; it does not mean the file is a summary of the EU AI Act rather than a polite refusal or a summary of the wrong corpus. Blackboard entries are structural claims, and quality claims need either a separate reviewing task on the board or an evaluation outside it. Teams that discover the pointer-and-checksum pattern sometimes over-read it and start treating a verified done as a verified result, which is a stronger statement than the fields support.

There is a cost to all of this that should be named plainly. The entry went from three fields to eleven, and every agent that writes it now has to get eleven fields right. Each additional field is a place where two agents can hold different conventions — whether owned_since is set at claim time or at first token, whether attempts counts claims or failures — and a shared document with eleven fields and four writers has more surface for divergence than one with three. The mitigation is that the fields are validated on write against a single schema definition shared by all four agents, so a disagreement becomes a rejected write rather than a subtly wrong document. That validation is worth setting up before the fourth agent joins rather than after.

None of the fields added here are safe from the mechanism the last section pointed at. owner, attempts and output are all written by agents that read the document, modified it in memory, and wrote it back whole, and two of those sequences overlapping does something to the document that no amount of schema design prevents.

Blackboard / shared-artifact architectures — read the current version at https://daviddominguez.dev/learn/en/blackboard-shared-artifact-architectures/a-done-flag-is-a-claim-not-a-result

Back to top