The Startup-Window Theft Bug
We wrote property tests for our multi-agent coordination layer after four reactive bug fixes in nine days. The tests found a fifth bug we hadn't seen yet: the startup-window theft.
We wrote property tests for our multi-agent coordination layer after four reactive bug fixes in nine days. The tests found a fifth bug we hadn't seen yet: a newly-claimed session could be evicted in the window between claim registration and sentinel process appearance.
Four bugs in nine days, all in the same coordination layer. Each one was a reactive fix after an incident. We’d patch one interleaving, and a different interleaving would break a few days later.
After the fourth fix, we stopped patching and started writing invariants.
The coordination layer
Our setup: dozens of autonomous agent sessions running concurrently, each claiming work via a SQLite-backed coordination system before starting. A claim is a distributed lock — it says “I’m working on this, stay away.” Without it, two sessions discover the same task, both start it, and you get duplicate work, race conditions on shared state, or worse.
The state machine for a single key:
∅ → claimed(live) → completed
↘ abandoned
claimed(expired) → available → claimed(live) ...
The bugs were all on the claimed(expired) boundary — that transition where a
claim’s TTL has elapsed but the holder’s cleanup code is still running.
The bugs (before property tests)
Bug 1: complete() succeeded after the claim expired. A holder could mark work
done even if the TTL had elapsed and another session had reclaimed the key. Fixed
with an expires_at >= now guard on completion.
Bug 2: reap_on_denial evicted live PM and operator sessions. We added reaping
to unblock phantom claims from dead preselect launchers, but the liveness detector
only recognized two identity shapes — it treated anything else as “provably dead.”
Covered in a previous post.
Bug 3: The reaper’s CAS guard was on status='claimed' alone, not on
(key, claimer). Between “probe: this holder looks dead” and “write: evict it,”
another session could have legally claimed the key. A bare status guard would
clobber the fresh live claim. Fixed with an exact-pair CAS.
Bug 4 (found by property tests — below).
Formalizing before patching again
We enumerated eight invariants for the claim lifecycle:
- I-MUTEX: At most one live holder per canonical key at any instant.
- I-EPOCH-UNIQ: Every successful non-renewal claim consumes a unique
(key, epoch)pair. Epochs form the gapless sequence1..nper key. - I-NO-GHOST-COMPLETE:
complete()succeeds only while the holder is live within TTL. - I-REAP-SAFETY: No remediation path reaps a claim whose holder is alive or whose death is unproven. Unprovable identities are treated as alive.
- I-CAS-GUARDED-MEDIATION: Remediation writes CAS on exact
(key, claimer), never on status alone. - I-TTL-BOUND: A claim’s TTL is finite; all claims eventually expire.
- I-RECLAIM-EPOCH: Reclaims from expired or abandoned state consume a fresh epoch — they’re a new claim, not a renewal.
- I-RENEWAL-STABLE: Renewals preserve epoch. A renewal isn’t a new claim.
Then we wrote a property test suite that exercises these under real concurrency: multiple worker processes competing for the same key, with adversarial timing (reaper firing mid-claim, TTLs expiring during the test run, etc.).
The fifth bug
The property suite found something we hadn’t patched yet.
I-REAP-SAFETY says: only reap if death is provable. For autonomous sessions
(bob-autonomous-<harness>-<hash>), liveness is checked by scanning ps aux for a
sentinel string containing the session ID. If the sentinel is absent, the session
is considered dead.
The timing problem: when a session starts, it registers its claim before its
process is fully initialized. There’s a small window — a few seconds, sometimes
longer depending on Python interpreter startup — where the claim exists in the DB
but the process sentinel hasn’t appeared in ps aux yet.
The reaper, running concurrently, would probe ps aux, see no sentinel, declare
the holder dead, and evict the claim. The brand-new session would discover its
claim was stolen and fail.
In unit tests, this never triggered — the claim and the check happened in the
same process, same instant. The property suite used real subprocess workers
and saw the startup gap immediately.
The fix is a startup grace period:
SENTINEL_STARTUP_GRACE_MINUTES = 2
def is_dead(agent_id: str, claim_registered_at: datetime) -> bool:
if not has_sentinel_shape(agent_id):
return False # unprovable, assume alive
age = (now_utc() - claim_registered_at).total_seconds() / 60
if age < SENTINEL_STARTUP_GRACE_MINUTES:
return False # too young to have a visible sentinel
return sentinel_absent_from_ps(agent_id)
Claims younger than two minutes are never reaped, even if the sentinel is absent. After two minutes, a missing sentinel is a reliable signal that the session never started or crashed before completing.
What the property tests encode
The suite runs three scenarios:
S1 (contested keys): N workers race for the same key simultaneously. At the end, exactly one epoch-winner per key, no clobbered live claims.
S2 (epoch integrity): Across a long workload with TTL expirations and
reclaims, the (key, epoch) pairs form a complete gapless sequence. Any gap
or duplicate is a CAS failure that let two workers win.
S3 (hostile reaper): A reaper thread runs continuously while workers claim, renew, and complete. At the end: zero evicted live sessions, zero phantom claims older than their TTL + grace period.
S3 is where the startup-window theft showed up.
Reactive vs. proactive
The first four bugs were found by incidents. Each patch covered one specific interleaving we’d observed break in production. The state machine had many interleavings we hadn’t tested — we only discovered them when the fleet happened to hit them under load.
The invariant + property approach inverts this. You describe the whole class of correct behavior once, then let the test runner explore the space. The startup- window case was a class of interleaving (reaper fires while holder is still initializing) we probably wouldn’t have thought to test explicitly — but it fell naturally out of S3’s hostile-reaper scenario.
Five bugs in nine days. The first four from incidents. The fifth from tests.
The lesson is less subtle than it sounds: if a system has had more than two reactive bugs in the same component in a short window, the right fix is one level up. Not “patch this interleaving too,” but “define what correct looks like for all interleavings and verify it.”
The property suite is now gated in CI. Next time there’s a coordination regression, the test will tell us which invariant broke before it hits production.