2026-08-29 · Updated 2026-08-29 · 8 min read

Why shared Markdown TODO files fail under concurrent agents

A shared TODO.md under multiple agents loses completed work, hides who claimed what, and cannot compute what may start — three structural failures, one reproduced deterministically, each mapped to the guarantee a Git-native task ledger provides instead.

By Juno AI INC · yylo-ledger · concurrency

The shared TODO file is where agent coordination usually starts, because it asks nothing of anyone: drop a Markdown checklist next to the code, let agents add items and flip brackets, and assume Git will catch whatever matters. With one writer this mostly holds together. Run several agents concurrently and it starts losing work in ways that read as model flakiness — an item somebody checked off comes back unchecked, two agents ship overlapping fixes, a task everyone assumed was next never starts. The agents are not the problem. The file is, in three specific ways: its updates overwrite each other, its checkmarks carry no ownership, and its ordering is invisible to any dispatcher. This page dissects each failure, reproduces the destructive one deterministically, and names the property that closes it — with YYLO Ledger as the concrete implementation, verified against its released CLI and committed README on 2026-08-29.

Lost updates: two writers, one survivor

Every agent works a TODO file through the same three steps: read the whole file, edit the in-memory copy, write the whole file back. Nothing sits between the read and the write to notice that another writer did the same, so whichever writer goes last decides what the file remembers. The loss needs no crash and no malice — only overlap — and it reproduces in eight lines:

python
from pathlib import Path
todo = Path("TODO.md")
todo.write_text("# Plan\n- [ ] Fix login redirect\n- [ ] Add rate limits\n")
a, b = todo.read_text(), todo.read_text()  # both agents read the same bytes
a = a.replace("- [ ] Fix login redirect", "- [x] Fix login redirect")
b = b.replace("- [ ] Add rate limits", "- [x] Add rate limits")
todo.write_text(a)  # agent A completes its item
todo.write_text(b)  # agent B writes back from the stale read

Run it and count the finished items: one. Agent A followed the protocol, observed success, and its completed item reverted to unchecked the instant agent B wrote a snapshot taken before A's edit. No command failed and no conflict was logged — the file simply keeps the last writer's memory, and both agents leave believing they finished.

Version control does not rescue this, for two separate reasons. When agents share one checkout, the loss happens at write time, before any commit exists to compare. When each agent works in its own worktree, both edits commit cleanly and arrive at the merge as a conflict on one checklist line — which an agent then resolves by picking a side, reproducing the same silent loss one step later. A lock file helps only when it is held before the read, across the whole edit cycle — lock the write alone, which is where a bolted-on lock usually lands, and agent B's snapshot still predates it, so B's orderly write still erases A's item. The missing piece is not turn-taking. It is a revision each writer must present and the store must check — so a stale snapshot is refused before it can land, instead of quietly winning.

Ambiguous ownership: a checkmark cannot say who is working

Read the line - [ ] Fix login redirect as the second arriving agent and try to answer the only question that matters: has anyone started this? The line cannot say. Brackets encode exactly two states, not-done and done, so "in progress" in a shared file is either absent or written as prose beside the item. That prose patch fails machines in three ways at once: it carries no identity a dispatcher can query, no timestamp a sweep can call stale, and no account of what the claimant actually did. So two agents start the same item because nothing marks it taken, and a worker that dies mid-task leaves its items claimed forever, because nothing dates the claim. Teams invent conventions — initials, dates, a holding column — and every convention is one more paragraph the agents must parse and honor, which is exactly the parsing the file was supposed to spare them.

The property that closes this failure is that taking work is typed state with a mandatory account attached. In YYLO Ledger, a claim is a status transition, and the CLI refuses one that happens silently: mark in_progress without --response stops as a usage error, printing "Agent response is required. Use --response TEXT or --response-file path.md" and changing nothing. With the response supplied, the claim lands as state any process can read — an account of who took the work, recorded on the task itself, dated by the tool rather than by the claimant. What those transition semantics buy as durable task memory is argued by the task-truth guide, and converting stale claims back into available work is queue hygiene with its own operations manual. The contrast this page owns is narrower and starker: a bracket that says nothing, versus a claim that must say something and cannot be written without saying it.

No readiness: sentence order is not a dependency graph

"Rewrite the auth module first, then delete the legacy shim" is an ordering a human can honor and a dispatcher cannot use. Handing admission to machines requires something that answers, at any instant, which items may start now — and a TODO file cannot, because its dependencies live in sentence order, its statuses are brackets, and its "done" means whatever the last writer believed at the time. Three failure shapes follow. Duplicated effort: two agents start items that both assume an untracked prerequisite and rebuild the same thing twice. Deadlock by etiquette: an agent skips an item "until the auth work lands" while the auth work sits finished but unrecorded, so the release never fires — the dependency was prose, so nothing recomputes it. Impossible schedules: an ordering spread across paragraphs quietly cycles, and nothing detects the loop, because the loop is not data.

Readiness must be computed from declared state instead of inferred from prose. That is the ledger's core trade: dependencies are a field, admission is a query, and the README compresses the query into one line — "ready command finds unblocked tasks for parallel execution". Two refusals keep the computation honest: finishing a task while a declared blocker still lacks its terminal status is refused outright — the README's rule is "Every generic mutation to done is refused before any task/ledger write when a declared blocker is missing or non-terminal" — and reopening a resolved blocker that finished dependents rely on is refused the same way, so the graph cannot be made to lie retroactively. Cycles never get that far: declaring an edge that would close a loop is refused at write time with the offending IDs named.

We exercised the held-and-released behavior against the released CLI on 2026-08-29. With Delete the legacy shim declared blocked-by Land the auth refactor, ready listed every actionable task except the dependent while the blocker was open; the same query, run immediately after the blocker's completion landed, included the dependent. No dispatcher logic and no prose parsing — the answer moved because the state did.

The three guarantees, exercised live

Each failure maps to one guarantee, and the mapping is short. Lost updates close when state is split per task and guarded per revision — the README states the protection as "Per-task locks/CAS receipts and segmented, hash-chained ledgers under .juno_task/ledger/", which means writers on different tasks never touch the same file, and writers on one task are serialized and checked. Unowned claims close when transitions carry mandatory responses and tool-written timestamps. Uncomputed readiness closes when edges are data and admission is a query over that data.

Both concurrency guarantees were run against the released CLI on 2026-08-29. Two concurrent mark in_progress processes on two different tasks both exited zero, and the lock directory showed one lock file per task — neither writer contested the other's bytes. The same race on a single task, guarded, ended in a refusal instead of a loss. Writer A claimed the task and moved it forward through two recorded mutations, taking a receipt on the first; writer B then presented that receipt's revision while A's second mutation had already superseded it:

text
yylo-ledger update 66Azxv --response "Writer B: adding my findings" \
  --expected-revision adadec3c29385e3fe2c9823c50b0e3e9872180611484b05aca56e77e3fb3c17b
Error updating task: stale task revision for 66Azxv: expected adadec3c29385e3fe2c9823c50b0e3e9872180611484b05aca56e77e3fb3c17b, current 624c9e75e3bcb4a2b351dd3d3118b32664384a2f4caf09546b1ffddb32bd75f8

That error is the entire argument compressed: the stale writer is refused loudly, both revisions are named, and the writer that moved first keeps its work. Contrast the TODO file, where the equivalent event is agent B's write succeeding and agent A's completed item vanishing without a message. Re-reading the task costs one command, and the retry then builds on what actually happened. How these guarantees are implemented on disk — the file format, the lock and recovery machinery, whole-board merges — is the storage-format guide's territory, linked here rather than retold.

Migrate one lane at a time

The failure is not that Markdown is a poor medium — a file you alone edit can stay prose forever. The failure is that one mutable file is a poor substrate for concurrent writers: whoever writes last owns the truth, and everyone else's edits are suggestions. The migration that fixes it is one lane at a time. Install YYLO Ledger from PyPI, run its first command inside the repository your agents share, move one workstream into tasks with declared edges, and ask ready what may start before each dispatch; the first race you would have lost now ends as a refusal you can read.

Where this diagnosis hands off

Initialization and the on-disk contract underneath are the storage-format guide's ground; the semantics that make such state trustworthy as agent memory belong to the task-truth guide; running waves of it unattended is the autonomous queue guide's. Designing dependency graphs as executable structure is the dependency-graph design guide's ground. Coordinating several agents with no shared mutable state at all is the multi-agent coordination guide's ground. The Ledger documentation carries the complete command surface, and the checklist can keep doing what it was always good at: being read by exactly one person at a time.