Repository navigation
Work-claim conflict protocol: waitlist, yield request over a2a, and arbitration #15948
Description
Activity
API as landed — two changes that affect this issue's design
Wave 1 is in review at #15958 and the storage model changed under review. Worth reading before designing the waitlist against the sketch above.
Claims are keyed per holder, not per path —
{path}|{agent}|{task}. A path can therefore have several live claims (all SHARED). That matters here: a waitlist entry cannot be "the next claim onpath:a/b", because releasing one SHARED holder does not free the path. The promotion condition is "no remaining claim overlaps the waiter's scope in a conflicting mode", which is the same predicate the acquire Lua already computes — so the waitlist should call it rather than restate it. Restating it would create the third copy of the overlap rule, and #15958 already had to add an exhaustive test to stop the second one drifting from the first.try_acquirehides a fresh-vs-renew distinction that this issue needs. Internally_acquirereturns("acquired"|"renewed", claim); the public wrapper drops the verdict. A waiter promoted off the queue needs to know it genuinely took the scope rather than renewed something it already had — so either surface the verdict or call_acquiredirectly, and say which in the AC.Also:
ClaimConflict.holder.expires_atis now truthful after a renew (it was stale — a refused agent was told the holder had already expired). The waitlist's "how long until I can retry" logic can rely on that field; before #15958's second commit it could not.Scope kinds are
path,kb,device,project,config— nofile/dir, see the correction on #15947.Mode 3 needs a subscription, not a one-shot request — and messages need an evidence rule
Owner's point on #15987: when changes are interdependent, agents are aware of each other's edits
and talk. That extends this issue in two ways.1. From request/response to subscription
The spec above covers a one-shot exchange: a refused agent asks the holder to yield, the holder
answersyieldorhold. Mode 3 needs the continuous form — while two agents share a branch,
each learns when the other's work lands on it.- The unit subscribed to is the branch, not the claim. A claim answers "may I", a branch
answers "what changed" — and in mode 3 the claim is not contested, it is shared. - It rides the existing a2a channel, as the yield request does. No new bus (
EVENT_STATE_DOCTRINE
principle 1). - The conversation is scoped to the scope, not to a single request, so context accumulates
where the next agent to touch that scope will find it.
2. Messages between agents are claims, not facts
This is the rule with the most evidence behind it, and the evidence is a live multi-session fleet
working this repository on 2026-09-07 — the design's own prototype. Four coordination errors
occurred, none malicious, all the same shape: an assertion passed between agents and acted on
without verification.- A session reported feat(llc): the three remaining agent routes do their work (#15905) #15920's
uuid.UUID(company_id)finding as open and unaddressed. It had
already been fixed at_common.py:136-141. Caught only because the receiver read the branch head
instead of the message. - The coordinating session read two differently-worded reports of one defect as two distinct
findings and blocked a merge on the second. Retracted after verifying against the head. - The same session attributed an unexplained merge to a GitHub Actions workflow. Structurally
impossible: a remote workflow cannot write an entry into a local reflog. The real cause was a
local hourly cron, found in one step once the class of candidate was corrected. - A figure — "17 of 18 worktrees belong to no live session" — was published mid-sweep, repeated by
another session, and written into Unlanded changes keep a scope locked: hand stewardship of the branch to the next agent #15987 as design rationale. The measured result was 13 of 17.
Every one was recoverable, and every one cost a round trip or a wrong action. The rule that falls
out:A message asserting a fact carries what would let the receiver check it — a
file:line, a SHA, a
command, a count and how it was measured. A receiver acts on state it can observe, not on the
assertion. Stale-by-default: a claim about code is true of the commit it was made against and
nothing later.This is
EVENT_STATE_DOCTRINEprinciple 3 applied to agents rather than clients: the backend is
the authority, a client may render optimistically but is never the source of truth, and two
clients that each own the truth cannot converge. Agent chatter is a projection of branch and
claim state, never a substitute for reading it.Added acceptance criteria
- Branch-level change notification exists for agents sharing a scope, on the existing a2a
channel — no new bus - The conversation is addressable by scope, so context is where the next agent will look
- A message asserting a fact carries a verifiable reference (SHA,
file:line, or the command
that produced a count); a test or lint asserts the payload has somewhere to put it - Assertions are timestamped against a commit, so a receiver can tell a stale claim from a
current one -
docs/developer/AGENT_COORDINATION.mdstates the claims-not-facts rule with the four
instances above as the evidence, so it reads as a measured finding rather than caution
- The unit subscribed to is the branch, not the claim. A claim answers "may I", a branch
A contended resource the scope grammar cannot name: the shared git index
Reported by another session in this fleet, from a live failure rather than a hypothetical, and it belongs here because it is cross-session interference that the claim registry is blind to.
A test fixture ran
git init/git addwithout scrubbing the environment. Under theGIT_DIRa hook exports, thegit addstaged a decoy file into the shared index rather than the fixture's throwaway repository. It passed standalone and failed on push.Two things matter for this issue.
One session's test can dirty another session's working tree. All worktrees of a clone share the object store and, when the environment redirects them, the index. That is a resource two agents contend on with no claim, no scope name, and no observable holder — the contention is invisible until someone's unrelated push fails.
work_claimscannot express it: a claim onpath:...names a file in a tree, and the shared index is not in any tree.This is the same gap as the torch/ansible case recorded on #15987, from a third direction. There, two manifests shared a concept no scope could name. Here, two sessions share git state no scope can name. Both say the same thing: the grammar addresses locations, and some contended resources are not locations. Whether scopes can name non-location resources is now the same open decision in two places, and it should be answered once.
And the smaller rule, which is exact: a fixture that demonstrates a hazard must not be subject to it. The fixture existed to exercise unscrubbed-git behaviour and was itself unscrubbed, so it did the damage it was written to detect — the same shape as #15947's
_require_holder, a guard that read as present while being absent from behaviour, and as a test asserting a return type for a property about storage.Added acceptance criteria
- The decision on whether a scope may name a non-location resource (shared git index, a version constant spanning manifests) is recorded once, here or on Unlanded changes keep a scope locked: hand stewardship of the branch to the next agent #15987, and the other issue links to it
- If scopes stay location-only, the gap is filed with what covers it instead
- added 8 commits that reference this issue
on Sep 7, 2026 Closing on code evidence verified against merged
origin/Dev_new_gui, not against the PR and not on the merge. Merged as #15992 at 2026-09-08T06:42:27Z.Criterion Evidence in merged base waitlist_join/waitlist_leave/ next-in-line, TTL'dclaim_waitlist.py:212join,:268leave,:291waiters,:305next_waiter,:318depth;WAIT_TTL_Senv-backed and registeredReleasing promotes the head; an expired waiter is skipped, not stuck _WAITERS_LUA:183prunes on every read;invite_next_waiterinclaim_yield.py;test_an_expired_waiter_is_skipped_not_stuckYield request and response ride the existing a2a channel — no new bus claim_yield.py:39from a2a.task_manager import get_task_manager,:106publish_event. Grep proof: the module imports no bus and defines noneTimeout on an unanswered yield resolves to hold, timeout env-backed:51YIELD_TIMEOUT_S = env_int_clamped("AUTOBOT_WORK_CLAIM_YIELD_TIMEOUT_S", 30, min_v=1, max_v=600);await_decisionreturnsHOLDon expiryArbitration is a single pure function, each branch tested :92arbitrate; one test per branch plus an order-independence property testA conflicted agent that loses receives the holder's identity and intent ClaimConflictfrom #15947 carries agent, task, mode, intent, expiry; the queue entry carries the sameTests in autobot-backend/tests/coordination/test_claim_waitlist.py(279 lines),test_claim_yield.py(179 lines)The design decision worth carrying forward
Promotion is an invitation, not a grant. Nothing computes whether a waiter could acquire — it names who is next and the waiter calls
try_acquireitself.The alternative was to evaluate "does anything still overlap this scope in a conflicting mode" at promotion time. That predicate already exists twice —
Scope.overlapsand the acquire Lua — and #15947 needed an exhaustive 6,084-pair test to stop those two drifting. A third copy would have to be pinned to both. Promotion by retry reuses the predicate by using it.It also makes the multi-holder case correct without modelling it: a path carries several SHARED claims, so one release need not free it. A waiter promoted on "the holder released" would wake to a scope still held; a waiter that retries fails and keeps its place. That is a test, not a caveat.
What review caught, recorded because the pattern is the point
Six defects, and four were assertions that would have passed without the behaviour they named:
- the SHARED test asserting a return type for a property about storage,
- the expired-waiter test deleting the whole list key instead of ageing one entry,
- a timing bound set above the timing it measured,
pytest.raises(ValueError)wide enough to catchHolderError, which subclasses it.
Plus two real bugs:
joinwas read-then-write (in a module whose sibling is Lua specifically because read-then-write cannot promise atomicity), and a short-lived rejoin shortened the shared key's TTL, evicting waiters with hours left.Every one was written immediately after fixing the defect it covers. That timing looks causal: when the fix is fresh you write down what you changed rather than what would detect its absence.
Deferred, not dropped
Branch-level change subscription and the inter-agent message evidence rule — both recorded on this issue today — change the a2a message envelope rather than the claim layer, so they split rather than batch. They remain criteria on #15987 and on this issue's follow-up scope.
Parent: #15946
Problem
A refused claim is only useful if the loser can do something about it. #15946's Task 1
returns a
ClaimConflictnaming the holder; this task turns that into a negotiation.Scope
Waitlist. An agent refused a scope may enqueue behind the holder. When the holder
releases (or its TTL expires), the head of the queue is notified and may acquire. The queue
is Redis-backed with its own TTL — a waiter that dies is dropped, never inherited.
Yield request. The refused agent may ask the holder to release early. The request rides
the existing a2a task channel (
a2a/task_manager.py::publish_event) — no new bus, no newprotocol. The holder answers
yieldorhold; silence past a timeout counts ashold.Arbitration. When two agents want the same scope and neither yields,
agents/agent_orchestration/coordinator.pydecides on a stated policy: task priority first,then earliest
created_at, then agent id as a stable tiebreak. The policy must be onefunction with a test per branch, not a rule scattered across call sites.
Acceptance criteria
waitlist_join/waitlist_leave/ next-in-line notification implemented, TTL'dhold, with the timeout from an env-backed constantautobot-backend/tests/coordination/Implementation Order
Wave: 2
Depends on: #15946 Task 1