Phase 3a emits a `leadership_status` observer frame per window-instance
every 5s and handles a `claim_leadership` control frame. The desktop had
no consumer. This adds the owner-side surface: a per-agent leader badge
and a per-instance "Make leader" steal action.
The frames already land in `eventsByAgent` via the owner-wide observer
subscription, so leadership is a cached derivation rather than a new
store. `getAgentLeadership` stays a stable map lookup (required by
`useSyncExternalStore`); the `leadershipByAgent` array is rebuilt only
when a leadership frame appends. Staleness stays out of the store — the
row filters against a 5s clock so a crashed leader's badge drops within
15s without a new frame. The steal ack is non-authoritative: the UI
converges off the stream, never optimistically flipping the badge.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The read side elected nobody: with no writer, every instance read an
absent lock and defaulted to leader, so a shared agent key produced
duplicate responders. Add the writer half so one instance actually wins.
The harness self-mints a process-unique election id (from_env_or_mint),
acquires the lock on startup, re-attempts the claim on the existing 5s
refresh tick (failover when a leader dies without releasing), and
releases on graceful shutdown. acquire holds an exclusive flock across
the read-decide-write window to close the TOCTOU race; a dead-pid lock is
reclaimable, a live foreign lock is not. release empties the file rather
than unlinking it to avoid racing a fresh claim onto a detached inode.
Failover tolerates pid recycling: a crashed leader's pid may be reused by
an unrelated live process, which a bare pid-liveness probe would mistake
for the original leader and never take over, wedging the agent leaderless.
A claim is takeable when its pid is dead OR its claimed_at is older than a
staleness bound (2x the 5s refresh); a live leader rewrites claimed_at
every tick, so only an abandoned claim ages out, without evicting an
active leader.
Writer is Unix-only (flock); non-Unix gets an always-leader no-op,
mirroring the kill_process_group cfg fallback. NIP-LE amended to final
state: claim is auto-on-launch plus explicit re-claim.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The primary suppression surface — dispatch_pending — had no test; only
the heartbeat gate was covered, so a refactor of the shared leader gate
could silently ship a non-leader that still dispatches. Add the missing
leader/non-leader test pair.
Read-side mutex locks now read through poison rather than panic the event
loop, removing a latent footgun for when Phase 2 extends the critical
sections. Module-doc and NIP-LE now state that any IO error (not just an
absent lock) fails safe to leader, that this can produce a bounded ≤5s
transient duplicate during a concurrent rewrite, and that pid/claimed_at
are read-side-ignored.
Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Folds the NIP-LE spec into PR #1062 alongside the implementation so the
spec and the code it describes ship together.
Reconciles one wording gap against the shipped code: the draft's invariant
named two suppression surfaces for non-leaders (dispatch path, 👀 reaction)
but the implementation gates a third — the autonomous heartbeat prompt path
(dispatch_heartbeat). Adds bullet (c) so all three suppression surfaces in
the spec map to a gate in the code.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
run_prompt_task has a second caller besides dispatch_pending:
dispatch_heartbeat. The heartbeat prompt acts autonomously on the wire
(sends messages, approves workflows), so an ungated heartbeat let every
non-leader instance sharing one agent key self-prompt and act — the same
duplicate-actor bug the dispatch gate prevents, arriving through the
heartbeat door. Gating before pool.try_claim closes it; solo dev is
unaffected since an absent lock means leader.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
When multiple Buzz dev instances share an agent keypair, the relay fans
each mention out to every connected buzz-acp (NIP-01) and all of them
respond. Add a per-agent-key leader lock so only the leader instance
promotes queued events to prompts; non-leaders keep their queue
populated for UI rendering but stay silent.
Reads ~/.buzz/leader-locks/<pubkey>.lock: absent or self-owned means
leader, foreign-owned means observer, malformed fails safe to leader so
a corrupt lock never silences the only responder. Solo dev has no lock
file, so behavior is unchanged. The 👀 reaction fires at queue-push time
before dispatch, so it is gated separately from dispatch_pending.
The election identity is sourced from BUZZ_INSTANCE_ELECTION_ID behind a
single named constant. It is deliberately NOT the Tauri bundle
identifier (BUZZ_MANAGED_AGENT): that collides across same-class windows
(DMG + dev, or worktrees whose icon-gen fell back to the shared dev id),
which would let two windows both lead. Phase 2 writes a process-unique
value; Phase 1 reads whatever that env carries and defaults to leader
when it is unset.
Phase 1 of 3: read side only. Lock acquire/steal/failover (Phase 2) and
claim UI (Phase 3) follow.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>