Conversation
Multibox Increment 5. New state_store/failover.py answers what the claim log alone cannot: which peer is driving, and how a peer that was partitioned away is stopped from resuming when it returns. Tier-S heartbeats (HeartbeatDir): one file per (instance_id, epoch), instances/<instance_id>.<epoch>.hb, atomically REPLACED via os.replace rather than appended, so 3-5 boxes beating every 10s cannot flood a log whose every read is a full directory listing. Same Inc 4b durability order. Unreadable heartbeat evidence fails toward ALIVE (the opposite of the claim log), because wrongly declaring a live peer dead would let its claims be reclaimed under it. instance_projection.detect_stale_instances gains a transport-aware source; threshold semantics, the 300s default and the result shape are unchanged on both transports, and stale-claim reclamation itself is untouched fold-TTL behaviour. multibox_staleness_summary() exposes the data for Inc 7's MCP fleet_multibox_summary without editing mcp/. Primary election: the primary is the holder of the reserved resource orchestrator_lock, taken through the SAME claim protocol as any file. No election algorithm, no consensus, no quorum - the shared log is the arbiter. fold_primary() is a pure function over a list of record dicts and delegates liveness to fold_fs_claims rather than reimplementing it, so lowest-key-wins, TTL-at-fold, tombstones, skew-only-lengthens and corrupt-fails-closed all apply to the lock unchanged. Fencing: every takeover bumps a monotonic generation; a coordination write whose generation is below the fold's current one is REJECTED. The generation is carried inside the claim record as a second claimed path orchestrator_lock/gen/<N>, which is load-bearing rather than a smuggled field: two instances racing for generation N collide on the token as well as the lock, so a generation can never be occupied twice. 93 new tests in tests/test_failover.py; Inc 4a's 67, Inc 4b's 42 and the 22 contract tests unchanged and green. The fence is proven load-bearing by mutation, not assertion: deleting the comparison in assert_fenced turns 5 tests red, and test_fence_is_load_bearing_the_same_write_lands_unguarded shows why - the same write, same caller, same payload, is ACCEPTED when not routed through the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner
Author
|
Known gap: stacked PRs get no CI. This PR targets |
…754 adaptation) PR #754 makes identity.release_own_stale() raise NotImplementedError instead of returning an unconditional True while doing nothing. Inc 5 needs no call-site change -- it has no call site: its reclamation mechanism already IS the fallback, TTL expiry at fold time (ttl + max_skew), as failover.py documents. Pins that so it cannot silently regress when #754 lands: - 5 behavioural cells run the whole election/takeover/fencing/summary path under every shape the stub can present (raises, False, the old misleading True, symbol absent), including the falsifiable half: before the TTL a restarted incarnation gets NOTHING, which is the proof no proactive reclamation is running. - A falsified source scan asserts no state_store module calls release_own_stale without handling NotImplementedError, so a future call site must carry the TTL fallback or go red. Verified against PR #754 actual identity.py (swapped in, not committed): all 101 failover tests green. Nothing claims reclamation happened. Real epoch-tagged reclamation filed as a backlog item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…into feat/multibox-inc5-failover # Conflicts: # tests/CLAUDE.md
…into feat/multibox-inc5-failover # Conflicts: # tests/CLAUDE.md
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Multibox Increment 5 — stale-instance detection, stale-primary failover, and fencing generations. Stacked on
feat/multibox-inc4b-durability.Answers the one question the claim log alone cannot: which peer is driving, and how a peer that was partitioned away is stopped from resuming when it comes back.
1. Tier-S heartbeats (
HeartbeatDir)Tier L keeps appending one
instance_heartbeatevent per beat. Tier S cannot — 5 boxes at 10s is ~43k records/day in a log whose every read is a full directory listing. So a Tier-S beat is one file per(instance_id, epoch),instances/<instance_id>.<epoch>.hb, atomically REPLACED (os.replace), never appended, keeping the directory bounded by fleet size forever. Same Inc 4b durability order (temp → fsync bytes → replace →_fsync_dir),.hb.tmpinvisible to readers,instance_idsanitized in the filename with the true value read from the body (:is illegal on Windows).Unreadable heartbeat evidence fails toward ALIVE (mtime fallback; unreadable dir →
[]) — deliberately the opposite direction from the claim log's fail-closed, because wrongly declaring a live peer dead would let its claims be reclaimed underneath it.instance_projection.detect_stale_instances(store, threshold, source=None, now=None)gains the transport-awaresource(record list, callable, or anything withread_heartbeats()). Threshold semantics, the 300s default and the result shape are unchanged on both transports — pinned by a test that asserts the boundary on both paths — so the reclamation path stays transport-blind. Stale-claim reclamation itself is not changed: it staysfold_fs_claimsTTL behaviour. Inc 5 only makes staleness observable, viamultibox_staleness_summary()— the data surface Inc 7's MCPfleet_multibox_summarywill read.mcp/is not touched.2. Primary election
The primary is simply the holder of the reserved resource
orchestrator_lock, taken through the same claim protocol as any file — same settle window, same(lamport, epoch_ms, instance_id, uuid)sort key, same TTL-expiry-at-fold. No election algorithm, no consensus, no quorum: the shared log is the arbiter. A live holder is never pre-empted; losing a concurrent takeover is not an error (the log is re-folded and the actual winner returned, so N challengers converge with no retry loop).fold_primary(records, now, max_skew) -> PrimaryStateis a pure function over a list of dicts and delegates liveness tofold_fs_claimsrather than reimplementing it, so lowest-key-wins, TTL-at-fold, tombstones, heartbeat extension, skew-only-lengthens and corrupt-fails-closed all apply to the lock unchanged, for free.3. Fencing generations
TTL takeover alone is unsafe: a merely partitioned primary returns after its successor took over and both drive. So every takeover bumps a monotonic generation, and
assert_fenced/fenced_write/fenced_backend_writereject any coordination write whose generation is below the fold's current one.epoch(Inc 3) fences a restarted process against its own prior incarnation;generationfences a partitioned primary against its successor — different failure modes, both needed.PrimaryState.generationis the max over all lock records including expired and tombstoned ones — a dead record is still proof a generation was issued, and forgetting it would let the fence go backwards.The generation is carried inside the claim record as a second claimed path
orchestrator_lock/gen/<NNNNNNNNNNNN>. That is load-bearing rather than a smuggled field: because the token is itself a claimed path, two instances racing for generation N collide on the token as well as the lock, so a generation can never be occupied twice.backend_records()refuses a backend that exposes no listing (UnsupportedBackendError) instead of reading it as an empty log — which would report "no primary" and hand the lock to a second driver.Falsifiability: the fence is proven load-bearing by mutation, not assertion
Replacing the comparison in
assert_fencedwith a barereturnturns 5 tests red:test_fence_is_load_bearing_the_same_write_lands_unguardedshows why: the same write, same caller, same payload, is ACCEPTED when not routed through the guard, and produces no effect when it is. So the guard — and nothing else — is what stands between an old primary and split-brain.Test counts
tests/test_failover.py(new)tests/test_fs_claim_log.py(Inc 4a)tests/test_fs_claim_log_durability.py(Inc 4b)tests/test_claim_backend.py(Inc 2 contract)tests/test_instance_manager.pytests/test_multi_dispatch_claim.pytests/test_lease_claims.pyThe 93 break down as 7 generation-token, 21 pure
fold_primary(sole instance elected; lapse frees the lock while the fence remembers it; exactly one successor; 3-way simultaneous takeover → one winner and order-independent; generation never decreases; tombstone/heartbeat/skew/corrupt/pre-fencing-record/non-lock-claim; input not mutated), 12 fencing, 17 tmpdir integration with injected clock and settle=0, 14HeartbeatDir(50 beats → still 1 file — the whole reason Tier S exists), 12 transport-parity, 10 summary/dataclass.Full gate battery
npm run test:py4469 OK (20 skips) ·npm run test:node315 pass, 0 fail ·npm run test:sh14/14 suites PASS ·secret_scan --stagedexit 0 ·verify_test_coverage --check·claudemd_lint·claudemd_contract·claudemd_sync_gate --check·claudemd_drift·stateapi_lint·watcher_linter --check·spec_contract_validator --check·subprocess_guard --check·agent_prompt_hygiene·portability_check·self_stats --check·verify_test_suite_count --check(233 → 234) ·metrics_gate— all exit 0.Hermetic: tempdirs only, injectable clock, no real sleeps, no network,
encoding='utf-8'throughout, ASCII source.Scope
Touched:
state_store/failover.py(new),state_store/instance_projection.py(transport-aware stale source only),tests/test_failover.py(new),state_store/CLAUDE.md,tests/CLAUDE.md(regenerated count). Not touched:fs_claim_log.py,identity.py,claim_backend.py,paths.py,lease_claims.py,tools/**,ui/**,mcp/**,.github/**.Flag: reachable only under
multibox.enabled(wired in Inc 7). Tier L is untouched.🤖 Generated with Claude Code