Skip to content

Work-claim conflict protocol: waitlist, yield request over a2a, and arbitration #15948

Description

@mrveiss

Parent: #15946

Problem

A refused claim is only useful if the loser can do something about it. #15946's Task 1
returns a ClaimConflict naming 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 new
protocol. The holder answers yield or hold; silence past a timeout counts as hold.

Arbitration. When two agents want the same scope and neither yields,
agents/agent_orchestration/coordinator.py decides on a stated policy: task priority first,
then earliest created_at, then agent id as a stable tiebreak. The policy must be one
function with a test per branch, not a rule scattered across call sites.

Acceptance criteria

  • waitlist_join / waitlist_leave / next-in-line notification implemented, TTL'd
  • Releasing a claim promotes the head of its waitlist; an expired waiter is skipped, not stuck
  • Yield request and its response are carried by the existing a2a channel — grep proves no new bus
  • Timeout on an unanswered yield resolves to hold, with the timeout from an env-backed constant
  • Arbitration is a single pure function; each branch (priority / age / tiebreak) has a test
  • A conflicted agent that loses arbitration receives the holder's identity and intent, not a bare refusal
  • Tests in autobot-backend/tests/coordination/

Implementation Order

Wave: 2
Depends on: #15946 Task 1

Activity

  1. mrveiss commented on Sep 7, 2026

    @mrveiss
    OwnerAuthor

    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 on path: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_acquire hides a fresh-vs-renew distinction that this issue needs. Internally _acquire returns ("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 _acquire directly, and say which in the AC.

    Also: ClaimConflict.holder.expires_at is 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 — no file/dir, see the correction on #15947.

  2. mrveiss commented on Sep 7, 2026

    @mrveiss
    OwnerAuthor

    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
    answers yield or hold. 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_DOCTRINE principle 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.md states the claims-not-facts rule with the four
      instances above as the evidence, so it reads as a measured finding rather than caution
  3. mrveiss commented on Sep 7, 2026

    @mrveiss
    OwnerAuthor

    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 add without scrubbing the environment. Under the GIT_DIR a hook exports, the git add staged 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_claims cannot express it: a claim on path:... 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

  4. github-actions commented on Sep 8, 2026

    @github-actions
    Contributor

    PR #15992 (merged to Dev_new_gui) references this issue with a close keyword.

    feat(coordination): queue behind a held scope, ask the holder to yield, arbitrate (#15948)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  5. mrveiss commented on Sep 8, 2026

    @mrveiss
    OwnerAuthor

    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'd claim_waitlist.py:212 join, :268 leave, :291 waiters, :305 next_waiter, :318 depth; WAIT_TTL_S env-backed and registered
    Releasing promotes the head; an expired waiter is skipped, not stuck _WAITERS_LUA:183 prunes on every read; invite_next_waiter in claim_yield.py; test_an_expired_waiter_is_skipped_not_stuck
    Yield request and response ride the existing a2a channel — no new bus claim_yield.py:39 from a2a.task_manager import get_task_manager, :106 publish_event. Grep proof: the module imports no bus and defines none
    Timeout on an unanswered yield resolves to hold, timeout env-backed :51 YIELD_TIMEOUT_S = env_int_clamped("AUTOBOT_WORK_CLAIM_YIELD_TIMEOUT_S", 30, min_v=1, max_v=600); await_decision returns HOLD on expiry
    Arbitration is a single pure function, each branch tested :92 arbitrate; one test per branch plus an order-independence property test
    A conflicted agent that loses receives the holder's identity and intent ClaimConflict from #15947 carries agent, task, mode, intent, expiry; the queue entry carries the same
    Tests 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_acquire itself.

    The alternative was to evaluate "does anything still overlap this scope in a conflicting mode" at promotion time. That predicate already exists twice — Scope.overlaps and 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:

    1. the SHARED test asserting a return type for a property about storage,
    2. the expired-waiter test deleting the whole list key instead of ageing one entry,
    3. a timing bound set above the timing it measured,
    4. pytest.raises(ValueError) wide enough to catch HolderError, which subclasses it.

    Plus two real bugs: join was 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions