Skip to content

feat(cluster): worker registry persistence, split-brain protection, circuit breaker - #1928

Open
hognek wants to merge 2 commits into
jaylfc:devfrom
hognek:fix/cluster-persistence-splitbrain-circuitbreaker
Open

feat(cluster): worker registry persistence, split-brain protection, circuit breaker#1928
hognek wants to merge 2 commits into
jaylfc:devfrom
hognek:fix/cluster-persistence-splitbrain-circuitbreaker

Conversation

@hognek

@hognek hognek commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Closes #640.

Four interconnected changes for multi-node reliability:

1. Worker registry persistence — New WorkerRegistryStore (SQLite-backed via BaseStore) persists worker registrations so the in-memory ClusterManager._workers dict survives controller restarts. On startup, loaded workers are marked "stale" until they re-heartbeat. The "local" worker is skipped during load (it re-registers on every boot).

2. Split-brain protection via generation counter — A generation counter (stored in the same SQLite table) is incremented on each controller start(). Workers echo it in heartbeats; stale generations are rejected. Legacy workers that don't send generation get a pass (None).

3. Circuit breaker for failing workers — New FailureTracker with sliding window (default: 3 failures in 60s). TaskRouter skips tripped workers instead of serially blocking 60s * N_workers. Success clears the circuit; failures recorded per-worker.

4. In-flight routing recovery — Addressed by Fix 1: persisted workers survive controller restart; stale-until-heartbeat prevents routing to workers that haven't reconnected.

Files changed

  • NEW: tinyagentos/cluster/worker_registry_store.py — SQLite store for worker records + generation counter
  • NEW: tinyagentos/cluster/failure_tracker.py — Sliding-window circuit breaker
  • MODIFIED: tinyagentos/cluster/manager.py — init/start/heartbeat/register/unregister with persistence, generation counter, failure_tracker wiring
  • MODIFIED: tinyagentos/cluster/router.py — Circuit breaker skip + record on failure/success
  • MODIFIED: tinyagentos/app.py — Wire new stores into ClusterManager creation
  • NEW: tests/test_cluster_persistence.py — 24 tests covering store CRUD, generation counter, stale-on-load, heartbeat revival, split-brain rejection, circuit breaker tripping/skipping/clearing

Testing

  • 55 existing cluster tests + 24 new tests = 79 passed
  • Stores are optional (None = no persistence); all existing tests pass unchanged
  • Full gate running in background

Backward compatibility

  • ClusterManager.__init__ accepts new optional worker_registry_store and failure_tracker params — defaults to None
  • heartbeat() accepts new optional generation param — None = legacy worker, accepted
  • No DB migration needed — new tables created on first store.init()
  • Router gracefully handles failure_tracker is None (no circuit breaker)

@hognek
hognek marked this pull request as ready for review July 17, 2026 19:22
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@hognek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c8d5231-7336-4449-abc3-2d964c448c6f

📥 Commits

Reviewing files that changed from the base of the PR and between c5b1a6f and 64e753e.

📒 Files selected for processing (7)
  • tests/test_cluster_persistence.py
  • tinyagentos/app.py
  • tinyagentos/cluster/failure_tracker.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/cluster/router.py
  • tinyagentos/cluster/worker_registry_store.py
  • tinyagentos/routes/cluster.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

# taOS #640: split-brain protection — reject heartbeat from a worker
# that is echoing a different generation (another active controller).
# Legacy workers that don't send generation get a pass (None).
if generation is not None and generation != self._generation:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Split-brain protection is bypassed for any worker that omits generation (legacy / not-yet-upgraded agents).

if generation is not None and generation != self._generation: means a worker that simply does not send generation (or sends None) is always accepted, regardless of which controller it last talked to. In a true split-brain scenario where a stale controller is still alive and its workers never adopt the new generation, those workers would be happily accepted here — defeating the protection for the entire legacy fleet. Consider rejecting workers that should be on a generation once the fleet is expected to send one, or at least logging/metrics on how many None-generation heartbeats are accepted so the protection isn't silently inert.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

logger.info(f"Worker registered: {info.name} ({info.platform}, {len(info.capabilities)} capabilities)")

# taOS #640: persist worker to SQLite so the registry survives restarts.
if self._registry_store is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: register_worker performs no generation check, so split-brain protection only covers heartbeat, not the re-enroll path.

The new generation guard lives only in heartbeat() (line 248). A worker that re-registers via register_worker (the normal re-enroll flow on worker boot) is accepted unconditionally and persisted, even if it was last talking to a now-stale controller. When a controller restarts, its generation increments, but a worker that re-registers (instead of heartbeating) is admitted regardless — the exact split-brain case this PR targets. Either pass/verify generation through register_worker too, or document why re-registration is exempt and why that can't race with a live stale controller.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/cluster/manager.py Outdated
# taOS #640: persist updated worker state to SQLite.
if self._registry_store is not None:
try:
asyncio.get_running_loop().create_task(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Fire-and-forget create_task(self._persist_worker(worker)) has no error handling and is never awaited.

If _persist_worker raises inside the spawned task (e.g. DB locked, disk full, serialization error), the exception becomes an unretrieved task exception logged as a generic asyncio warning, and the worker's persisted state silently goes stale with no logger.exception like the other call sites have. It is also not awaited before stop(), so a controller shutdown can drop the final heartbeat's persistence. Wrap the task body in try/except (mirroring the register_worker/unregister_worker handling) and/or track it in self._background_tasks so it can be awaited/cleaned up on stop.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/cluster/failure_tracker.py Outdated
first_failure_at: float


# Sensible defaults: 5 failures in 60 seconds trips the breaker.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Comment says "5 failures in 60 seconds" but DEFAULT_FAILURE_THRESHOLD = 3.

This is misleading to readers/maintainers; the actual default trips after 3 failures. Either update the comment to match the constant or change the constant if 5 was intended.

Suggested change
# Sensible defaults: 5 failures in 60 seconds trips the breaker.
# Sensible defaults: 3 failures in 60 seconds trips the breaker.

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if self._db is None:
raise RuntimeError("WorkerRegistryStore not initialised")
await self._db.execute(
"UPDATE cluster_generation SET value = value + 1 WHERE id = 1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: increment_generation uses a separate UPDATE then SELECT, which is two round-trips and a non-atomic read of the value.

Use SQLite's RETURNING to get the new value in a single atomic statement, e.g. UPDATE cluster_generation SET value = value + 1 WHERE id = 1 RETURNING value. This removes the second query, avoids any window where another writer could shift the value between the UPDATE and the SELECT, and is supported by aiosqlite/SQLite 3.35+ (the same baseline the rest of the stores already rely on).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/cluster/manager.py 271 Split-brain guard bypassed when worker omits generation (legacy/None always accepted)
tinyagentos/cluster/manager.py 109 register_worker has no generation check — re-enroll path defeats split-brain protection
tinyagentos/cluster/manager.py 391 Fire-and-forget _persist_worker task has no error handling and is never awaited

SUGGESTION

File Line Issue
tinyagentos/cluster/failure_tracker.py 26 Comment says "5 failures" but DEFAULT_FAILURE_THRESHOLD = 3
tinyagentos/cluster/worker_registry_store.py 178 increment_generation uses separate UPDATE+SELECT; use RETURNING for atomicity
Files Reviewed (6 files)
  • tests/test_cluster_persistence.py
  • tinyagentos/app.py
  • tinyagentos/cluster/failure_tracker.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/cluster/router.py
  • tinyagentos/cluster/worker_registry_store.py

Fix these issues in Kilo Cloud

Previous Review Summary (commit 9f1034e)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 9f1034e)

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/cluster/manager.py 248 Split-brain guard bypassed when worker omits generation (legacy/None always accepted)
tinyagentos/cluster/manager.py 105 register_worker has no generation check — re-enroll path defeats split-brain protection
tinyagentos/cluster/manager.py 306 Fire-and-forget _persist_worker task has no error handling and is never awaited

SUGGESTION

File Line Issue
tinyagentos/cluster/failure_tracker.py 26 Comment says "5 failures" but DEFAULT_FAILURE_THRESHOLD = 3
tinyagentos/cluster/worker_registry_store.py 178 increment_generation uses separate UPDATE+SELECT; use RETURNING for atomicity
Files Reviewed (6 files)
  • tests/test_cluster_persistence.py
  • tinyagentos/app.py
  • tinyagentos/cluster/failure_tracker.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/cluster/router.py
  • tinyagentos/cluster/worker_registry_store.py

Fix these issues in Kilo Cloud


Reviewed by hy3:free · Input: 41.8K · Output: 3.4K · Cached: 255.2K

@hognek
hognek force-pushed the fix/cluster-persistence-splitbrain-circuitbreaker branch from 9f1034e to 46aa5a6 Compare July 20, 2026 02:25
hognek added 2 commits July 26, 2026 23:49
…ircuit breaker

Fixes jaylfc#640. Four interconnected changes:

1. **Worker registry persistence** — New WorkerRegistryStore (BaseStore + SQLite)
   persists worker registrations so the in-memory dict survives controller
   restarts. On startup, loaded workers are marked 'stale' until they
   re-heartbeat; local worker is skipped during load.

2. **Split-brain protection via generation counter** — A generation counter
   (stored in the same SQLite table) is incremented on each controller start.
   Workers echo it in heartbeats; stale generations are rejected. Legacy
   workers that don't send generation get a pass (None).

3. **Circuit breaker for failing workers** — New FailureTracker with sliding
   window (default: 3 failures in 60s). TaskRouter skips tripped workers
   instead of serially blocking 60s * N_workers. Success clears the circuit.

4. **In-flight routing recovery** — Addressed by Fix 1: persisted workers
   survive controller restart; stale-until-heartbeat prevents routing to
   workers that haven't reconnected.

Files:
- NEW: tinyagentos/cluster/worker_registry_store.py
- NEW: tinyagentos/cluster/failure_tracker.py
- MODIFIED: tinyagentos/cluster/manager.py (init, start, heartbeat,
  register_worker, unregister_worker, _persist_worker, _load_persisted_workers)
- MODIFIED: tinyagentos/cluster/router.py (circuit breaker skip/record)
- MODIFIED: tinyagentos/app.py (wire new stores)
- NEW: tests/test_cluster_persistence.py (24 tests: store, tracker,
  manager persistence, router circuit breaker)

Tests: 79/79 pass (55 existing + 24 new cluster tests)
- failure_tracker: fix comment (3 failures, not 5)
- manager: add generation check to register_worker (mirrors heartbeat guard)
- manager: add debug log for None-generation heartbeats from legacy workers
- manager: wrap heartbeat-persist fire-and-forget in try/except with background task tracking
- worker_registry_store: use RETURNING for atomic increment_generation
- routes: wire generation through WorkerRegister and HeartbeatBody models

Rebased onto origin/dev (8b97eb5a). All 96 cluster/persistence/worker-protocol
tests pass.
@hognek
hognek force-pushed the fix/cluster-persistence-splitbrain-circuitbreaker branch from 46aa5a6 to 64e753e Compare July 26, 2026 21:50
@hognek

hognek commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

All 5 Kilo findings addressed + rebased onto origin/dev (8b97eb5a):

WARNING (3 fixed):

  1. manager.py:271 — Added debug log for None-generation heartbeats from legacy workers (with self._generation > 1 guard to avoid noise on first boot)
  2. manager.py:109 — register_worker now accepts generation parameter + performs the same generation check as heartbeat()
  3. manager.py:391 — Wrapped fire-and-forget _persist_worker in try/except with background task tracking (_background_tasks.add + add_done_callback)

SUGGESTION (2 fixed):
4. failure_tracker.py:26 — Comment now says 3 failures (matches DEFAULT_FAILURE_THRESHOLD)
5. worker_registry_store.py:178 — Uses RETURNING value for atomic increment (fetchone before commit, matching existing RETURNING patterns in message_store.py)

Routes wired: generation added to WorkerRegister + HeartbeatBody models, passed through to register_worker() and heartbeat()

96/96 cluster/persistence/worker-protocol tests pass.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant