2026-08-29 · Updated 2026-08-29 · 11 min read
Select dependency-ready work for parallel agents
Feeding a pool of agent workers is a selection problem sitting on top of a readiness answer: scope the ready set so claims stay out, spend scarce slots where they unlock downstream work, and close the window where two dispatchers hand the same task to two agents.
By Juno AI INC · yylo-ledger · parallel
A pool of agent workers is only as disciplined as the function that feeds it. Somewhere between a board of open tasks and a set of launched agents stands one decision — which tasks, out of everything that exists, go to the workers that are free right now — and that decision is where parallel schedules quietly succeed or quietly rot. This page is about the decision, worked through with YYLO Ledger's ready family of queries as the implementation. Every command form below ran against the released CLI and README pinned by this site's package facts, on 2026-08-29, in a sandbox board built for this page; task IDs in the transcripts come from that recorded run, and a fresh run mints fresh IDs — the shapes are the contract. What readiness *means* — the graph semantics, edge direction, all-of joins, cycle enforcement — belongs to the dependency-graph guide; this page picks up at the operator's seat, where an admission answer arrives and work must be chosen from it.
Ready answers admission; selection is still yours
The command's own help states the whole contract in one line — "List tasks that are ready to work on (all blockers resolved)" — and the longer description pins the two facts a dispatcher actually trips on: "Show tasks with status in [backlog, todo, in_progress] whose blockers are all done/archive (use --status to filter and preserve status order, and --sort asc|desc to order by last_modified within each status)". Read those two clauses as warnings. The status range includes in_progress, so an unscoped query returns claimed work sitting next to untouched work. And the ordering is recency, newest first, so the answer is not a fair queue — it is a snapshot sorted by what changed most recently.
Both facts showed up immediately in the sandbox board. After one dispatcher claimed a task — mark in_progress with a response recording who took it — the bare ready query still listed that task, now first in line, because claiming updates last_modified and the default sort promotes exactly that. Nothing about the answer is wrong: the task's blockers were all resolved, its status was actionable, so it answered ready. The query computed admission correctly and left the dispatch judgment to the operator, which is the design's honest division of labor. The ledger never pretends to know your slot count.
That division is what makes selection a real step rather than a pipe. The failure mode it prevents is a dispatcher that treats the answer as an assignment list — feeds the first N rows to N workers, claims and all, oldest-be-damned — and then wonders why one task ran twice and another ran never. The sections below take the three parts of that failure in order: scoping the answer so it contains only assignable work, spending bounded slots on the work that unlocks the most, and coordinating the moment of claim so two dispatchers cannot hand out the same task.
Scope the answer before you slice it
Scoping is cheap, deterministic, and the difference between a dispatch list and a query result. The forms that matter to a dispatcher:
The second form is the dispatcher's default for a reason the live board made vivid: with one task claimed, --status backlog returned exactly the unclaimed work — the claimed task was absent, its summary counters showing two backlog tasks where the unscoped query had shown three statuses' worth. Status scoping is claim exclusion, purchased with one flag. Grouping inside the answer follows the order you name, which is why the README annotates the combined form with "backlog group first, then in_progress" — the groups arrive in the order you typed them, and --sort then orders *within* each group, as the --sort help line states: "Sort order by last_modified (asc: oldest first, desc: newest first)".
Tag scoping narrows by the board's own metadata, so a worker fleet split by area — backend workers, docs workers — can be fed from one board without one dispatcher's selection colliding with another's. In the sandbox, --tag backend returned the single backend task that was ready while the blocked backend work stayed invisible; the tag filter composes with readiness rather than replacing it, because it is applied inside the same indexed collection plan.
Two quieter guarantees are worth knowing about. Underneath the display ordering, the graph layer returns its base answer in a fixed order — "List of task IDs that are ready, sorted by ID for deterministic output." — so identical board state always produces the identical base set, and every difference you see comes from the scoping and sort you asked for, never from query mood. And the summary line survives --limit: the counters report Displayed: N of M total tasks with a status breakdown, so a bounded fetch still tells you the size of the full assignable set — the number you need before deciding whether this wave even fits your slots.
Spend scarce slots where they unlock work
Quota-aware selection starts from a fact the summary counters make checkable: the ready set is often larger than the worker pool. The pool cap itself — how many concurrent workers may run, kept under provider and machine limits — is execution ground, owned by the parallel execution playbook, and the full loop from admission to artifacts is the parallel workflow guide's end-to-end walkthrough. What belongs to selection is the choice inside the ready set: given more ready tasks than free slots, which subset do you admit?
The ledger answers the ranking half with priority scores. order --scores prints the whole open board in execution order with each task's score attached — on the sandbox board, built as a diamond plus three independent tasks, the scores read: schema root 3, each implementer 1, ship task 0, every independent task 0. The score counts downstream work that waits on this task; the graph design guide owns its exact definition and the scheduling rules that follow from it. For selection the operational reading is enough: among tasks the ready query will admit, higher scores buy more unblocked work per finished slot — the schema root sat at the head of the diamond and topped the board's scores, and finishing it moved two implementers into the ready set at once.
One boundary of that ranking matters at dispatch time, and it is worth knowing before it bites. The scores come from the topological sort of *open* tasks, so order --scores is a wave-creation instrument: run it when you declare the wave, record the ranking, and select from it. The moment open work sits behind a resolved blocker, the stranded remainder is reported as a loop and the sort stops answering — while a blocker that no open task waits on can complete without disturbing it (verified live both ways: finishing a leaf left order working; finishing the schema root, with implementers still open, stopped it) — the exact sharp edge, with its live transcript, is documented in the graph design guide. Mid-flight, per-task scores remain available on demand through deps TASK_ID, which returned the ship task's verdict (is_blocked, met and unmet blockers, its score) at each stage of the sandbox run where it was asked, and admission always remains the ready query. The discipline that falls out: rank once at creation, admit from readiness throughout, and let deps answer per-task questions when a worker asks why something is or is not theirs.
Starvation is a selector property
Boards do not starve work; selectors do. The ledger's answers are complete and deterministic, and still a dispatcher can starve tasks with two habits that each look harmless alone.
Recency bias is the first. The default sort is newest-first, and --limit takes the front of the line — composed, they always select whatever arrived last. The sandbox demonstrated it starkly: with five tasks ready, a new task created, and --limit 2 under the default sort, the selection was the new task and a *claimed* task; the two implementers that gated the ship task — the critical path of the whole board — did not make the cut. Repeating the same query under --sort asc returned exactly those two implementers, the oldest ready work, which the README's own annotation names in one clause — "oldest ready tasks first by last_modified" — for precisely this purpose. A dispatcher that must favor recency some of the time should do it as an explicit, bounded exception; as a permanent default under a limit it is a starvation machine dressed as a priority rule.
Fan-in starvation is the second, and it compounds with the first. A dependent waiting on two blockers is released by whichever lands last, so a selector that consistently deprioritizes one of those blockers stalls the entire join while the other blocker's slot spend buys nothing — the all-of semantics are the graph design guide's contract, and the selection-side rule is its mirror: when ready work includes tasks that share a dependent, do not let one of them age indefinitely behind newer work. The scores ranking at wave creation tends to handle this for you — blockers of a shared dependent outscore leaf work — which is the practical case for scoring the wave before it flies rather than rediscovering the graph during dispatch.
Claim staleness is the third face of the same disease, and it cuts the other way: a claimed task that stops moving — its worker died, its session hung — sits in in_progress forever, invisible to --status backlog scoping, holding any dependent behind it. Detecting and requeueing stale claims is a queue operation with its own contract, owned by the autonomous queue guide; from the selection seat the obligation is only to keep claims out of your admit set and to treat a wave that stops producing ready work as a signal to look, not a reason to widen the pool.
Two dispatchers need a claim protocol, not hope
Everything above assumes one selector. Add a second dispatcher — a teammate, a cron job, another agent — reading the same board, and selection acquires a race: both read ready, both like the same task, both hand it to a worker. The ledger's coordination primitive is the claim: mark in_progress TASK --response "who and why" moves the task out of unclaimed scope, the response is required and recorded — the refusal a missing response earns, and the anatomy of why that requirement protects the claim record, belong to the failure-anatomy guide — and claimed work is inspectable by anyone, the README's team-coordination example doing it in one line under the comment "See what teammates are working on".
What a claim is not, is a reservation. Verified live on the sandbox board: a second mark in_progress on an already-claimed task succeeded — exit zero, with no conflict refusal; its only stderr was the standing commit-hash nudge every mark without --commit prints — and it replaced the first dispatcher's response with its own. The transition is last-writer-wins, so the window between reading ready and writing the claim is unprotected, and even the claim itself can be silently re-taken. The fix is the same one databases reach for: compare-and-set. update accepts --expected-revision, documented in five words — "Fail if the normalized task revision changed" — and the revision is the task's content hash, which every mutation receipt exposes as before_sha256 and after_sha256. The drill that closes the window:
The refusal names both hashes, so the losing dispatcher knows exactly what happened: someone wrote first. Re-read the task, re-check it against the current ready answer, and pick again. With that, the claim protocol is complete and honest: scope selection to unclaimed statuses so claimed work never re-enters the admit set, claim before dispatch so ownership is visible, and guard the claim with the revision so visibility is also enforced — two co-dispatchers still see the same unclaimed candidates, and it is the guarded claim that keeps their assignments apart. Per-task locking underneath the writes is documented by the storage-format guide; this is the operator-facing half.
Where selection hands off
This page owns the operator's selection loop: scoping the ready answer, scoring the wave, admitting bounded subsets, and claiming without collision. Its neighbors each own the adjacent ground: the dependency-graph guide for the semantics the answers rest on; the parallel execution playbook for pool caps, worker isolation, and per-run evidence once selections become launches; the parallel workflow guide for the end-to-end cycle this loop repeats; the autonomous queue guide for staleness, requeue, and stop conditions; the task-truth guide for why this state deserves to be the memory your agents share; and the Ledger docs as the exhaustive command reference. Install YYLO Ledger from PyPI, set the board up beside the code it will schedule, and the next time workers come free, let the answer arrive scoped, ranked, and claimable — then choose.