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>