Skip to content

feat(control,worker,sdk): client & worker failover across control-plane nodes (I9b W3–W5) - #169

Merged
jackthepunished merged 2 commits into
mainfrom
feat/raft-client-failover
Jul 30, 2026
Merged

feat(control,worker,sdk): client & worker failover across control-plane nodes (I9b W3–W5)#169
jackthepunished merged 2 commits into
mainfrom
feat/raft-client-failover

Conversation

@jackthepunished

@jackthepunished jackthepunished commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Motivation

I9a stood up a real multi-node control plane; I9b W1/W2 made its leader
redirect dialable. This closes the three remaining gaps from
docs/phase-5-plan.md §VII.1 that made the cluster unusable from outside —
after this, a client or worker pointed at any node keeps working across a
leader kill.

What changed

1. A completed action is never discarded for a routing accident (D1)

The scheduler's post-execution action-cache write is best-effort on
NotLeader only
: the build returns its real result, flagged uncached.
Previously a --raft follower failed the whole Execute with INTERNAL
after the sandbox had already run.

The recorded objection to best-effort was that it hides a routing problem as a
cache-hit-rate problem — so that objection is implemented as work:

  • a Scheduler::uncached_results_not_leader counter,
  • a warn naming the digest, leader id and address, and
  • the fact surfaced to the client in ExecuteResponse.message, so "ran and
    cached" and "ran, not cached" are distinguishable without server logs.

And it is narrow: a Redb or throughput error still fails the RPC, with a
named test for exactly that. Best-effort must not become best-ignored.

2. Workers survive losing the node they attached to

--control / --worker-control are repeatable; run_worker rotates on
failure and re-registers (the registry is per-node and ephemeral by ADR
design, so a new node has never heard of this worker).

rotation_plan(len, attempt) is a pure function: the whole first cycle runs
with zero delay
— the survivors are up now, so backing off before trying
them adds latency for nothing — then exponential backoff per completed cycle
capped at 30 s, with attempt-derived jitter so co-restarted fleets do not
reconnect in lockstep. A session that registered resets the backoff, or a
worker running for hours would inherit an old outage's delay and crawl through
a failover it should sail through.

3. Clients follow the redirect

brokkr_sdk::redirect classifies a refusal;
BrokkrClient::{get_action_result, update_action_result} follow it, bounded by
MAX_LEADER_HOPS, keeping the followed connection so steady state is one hop.

Two details worth review:

  • A redirect is identified by its metadata, not by FAILED_PRECONDITION
    alone — the scheduler already returns that code for "no eligible worker", so
    code-only matching would retry an unrelated failure against a different
    node.
  • hint_to_url inherits the caller's scheme, so a redirect can never
    downgrade an https session to plaintext.

connect_any and a repeatable brokk run --control use the first node that
answers, reporting the last transport error rather than a synthetic
aggregate that hides whether the cause was refusal or a bad certificate.

A consequence stated plainly

Because D1 means Execute no longer fails on NotLeader, and the CLI never
calls the ActionCache directly (the scheduler does cache lookups server-side),
no in-tree client RPC returns a leader redirect through brokk run. The
surface that matters is the REAPI one an external client (Bazel) drives — hence
the new SDK ActionCache methods, so the policy has a real consumer rather than
being exported and called done.

How it was tested

19 new tests, each written before its implementation. Extracting
rotation_plan and redirect::classify as pure functions and testing them
exhaustively caught four bugs no happy-path integration test reaches: a
1 << n overflow panic after ~64 reconnect cycles, a modulo-by-zero on an
empty endpoint set, an https→http downgrade, and a redirect cycle that hung
instead of terminating.

  • 2 scheduler tests for D1 (result returned uncached and counted; a
    non-NotLeader failure still fails the RPC)
  • 6 for rotation_plan; 2 for endpoint pairing
  • 6 for classify / hint_to_url
  • 3 integration tests in leader_redirect.rs driving real
    ActionCacheService servers
    : a client on a follower reaches the leader for
    read and write; an unroutable hint surfaces the original refusal with its
    metadata intact; a self-referential hint terminates on the hop budget.

Verified in WSL2: fmt, clippy --workspace --all-targets -D warnings,
RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps (exit 0), and
cargo test --workspace --no-fail-fast48 suites, the only failures
being the 6 evil_seccomp_caps tests that need a real kernel for seccomp
argument filters (docs/brokkr-development-chronicle.md:280).

No new dependency. --raft off remains the single-node path.

Related

docs/plan.md §17 task 7; docs/phase-5-plan.md §VII.2 (decision D1) and
§VII.3 (W3–W5); ADRs 0008–0010 (why the worker registry is ephemeral), 0011
(auth planes), 0013 (custom Raft); docs/journal/phase-5.md (## I9b W3–W5).
Next is W6 — the real-process kill-the-leader end-to-end.

Summary by CodeRabbit

  • New Features

    • Added support for connecting to multiple control-plane endpoints.
    • Clients can automatically follow leader redirects while preserving secure connections.
    • Workers automatically reconnect and rotate between control-plane nodes after interruptions.
  • Bug Fixes

    • Completed actions are preserved when cache updates encounter a non-leader node.
    • Clients now receive clear status information when results are not cached.
    • Redirect loops and unreachable leader hints are handled safely.
  • Documentation

    • Updated the Phase 5 changelog and journal with failover behavior and remaining milestones.

…ne nodes

Milestone I9b W3-W5 (`docs/plan.md` §17 task 7, `docs/phase-5-plan.md`
§VII.3), completing I9b.

I9a stood up a real multi-node control plane and I9b W1/W2 made its leader
redirect dialable. This closes the three remaining gaps that made the cluster
unusable from the outside.

**A completed action is never discarded for a routing accident** (decision D1,
owner 2026-07-30). The scheduler's post-execution action-cache write is now
best-effort *on `NotLeader` only*: the build returns its real result flagged
uncached. Previously a `--raft` follower failed the whole Execute with
INTERNAL **after** the sandbox had already run.

The recorded objection to best-effort was that it hides a routing problem as a
cache-hit-rate problem, so that objection is implemented as work: a
`uncached_results_not_leader` counter, a `warn` naming digest and leader, and
the fact surfaced to the *client* in `ExecuteResponse.message` so "ran and
cached" and "ran, not cached" are distinguishable without server logs. And it
is narrow -- a `Redb` or throughput error still fails the RPC, with a named
test for exactly that, because best-effort must not become best-ignored. No
retry: this node is not the leader and the only store reachable from it is the
one that just refused; forwarding is §VII.2 option (b), a different design.

**Workers survive losing the node they attached to.** `--control` and
`--worker-control` are repeatable, `WorkerConfig` carries a
`Vec<ControlPlane>`, and `run_worker` rotates on failure and re-registers --
necessary because the worker registry is per-node and ephemeral by ADR design,
so a new node has never heard of this worker. The policy is a pure function:
the whole first cycle runs with zero delay (the survivors are up *now*;
backing off before trying them adds latency for nothing), then exponential
backoff per completed cycle capped at 30s with attempt-derived jitter so
co-restarted fleets do not reconnect in lockstep. A session that registered
resets the backoff, or a worker running for hours would inherit an old
outage's delay and crawl through a failover it should sail through.

**Clients follow the redirect.** New `brokkr_sdk::redirect` classifies a
refusal and `BrokkrClient::{get_action_result, update_action_result}` follow
it, bounded by `MAX_LEADER_HOPS` and keeping the followed connection so steady
state is one hop. A redirect is identified by its *metadata*, not by
FAILED_PRECONDITION alone -- the scheduler already returns that code for "no
eligible worker", so code-only matching would retry an unrelated failure
against a different node. `hint_to_url` inherits the caller's scheme so a
redirect can never downgrade https to plaintext. `connect_any` and a
repeatable `brokk run --control` use the first node that answers, reporting
the last transport error rather than a synthetic aggregate that would hide
whether the cause was refusal or a bad certificate.

Stated plainly rather than glossed: because D1 means Execute no longer fails
on `NotLeader`, and the CLI never calls the ActionCache directly, no in-tree
client RPC returns a redirect through `brokk run`. The surface that matters is
the REAPI one an external client (Bazel) drives -- hence the new SDK
ActionCache methods, so the policy has a real consumer instead of being
exported and called done.

Extracting `rotation_plan` and `redirect::classify` as pure functions and
testing them exhaustively caught four bugs that no happy-path integration test
reaches: a `1 << n` overflow panic after ~64 reconnect cycles, a
modulo-by-zero on an empty endpoint set, an https->http downgrade, and a
redirect cycle that hung instead of terminating.

Tests (19 new, each written before its implementation): 2 scheduler tests for
D1; 6 for `rotation_plan`; 2 for endpoint pairing; 6 for
`classify`/`hint_to_url`; and 3 integration tests in `leader_redirect.rs`
driving real `ActionCacheService` servers -- a client on a follower reaches
the leader for read and write, an unroutable hint surfaces the original
refusal with its metadata intact, and a self-referential hint terminates on
the hop budget.

`BASE_BACKOFF`/`MAX_BACKOFF` are public: they are the policy, and public docs
cannot link private items under `RUSTDOCFLAGS=-Dwarnings`.

Verified in WSL2: fmt, `clippy --workspace --all-targets -D warnings`,
`RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps` (exit 0), and
`cargo test --workspace --no-fail-fast` -- 48 suites, the only failures being
the 6 `evil_seccomp_caps` tests that need a real kernel for seccomp argument
filters.

No new dependency. `--raft` off remains the single-node path.
Copilot AI review requested due to automatic review settings July 30, 2026 14:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 42 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 494cd0ea-2b9c-4519-b7fe-03c1cdd5b305

📥 Commits

Reviewing files that changed from the base of the PR and between cfe886d and 7c0b523.

📒 Files selected for processing (1)
  • crates/brokkr-sdk/src/client.rs
📝 Walkthrough

Walkthrough

Clients and workers now support multiple control-plane endpoints. SDK clients follow bounded leader redirects while preserving metadata and transport settings. Successful executions remain successful when action-cache writes return NotLeader, with cache status exposed to clients.

Changes

HA control-plane failover

Layer / File(s) Summary
Scheduler cache-write outcomes
crates/brokkr-control/src/scheduler.rs, crates/brokkr-control/src/services/execution.rs
Execution outcomes report whether results were cached; NotLeader cache-write failures preserve execution success, increment a counter, and populate the response message.
SDK redirect policy and client routing
crates/brokkr-sdk/src/*, crates/brokkr-control/tests/leader_redirect.rs
Clients classify leader metadata, reconnect using the original transport scheme, follow bounded redirects, try multiple initial endpoints, and preserve status metadata.
Worker endpoint rotation and re-registration
crates/brokkr-worker/src/*, crates/brokkr-control/tests/common/mod.rs
Workers accept paired control-plane endpoints, select worker URLs, rotate after session loss, and use capped exponential backoff.
Multi-endpoint CLI wiring and release records
crates/brokkr-cli/src/main.rs, CHANGELOG.md, docs/journal/phase-5.md
brokk run --control tries configured endpoints in order, while changelog and journal entries document the failover implementation and verification.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Follower
  participant Leader
  participant Worker
  Client->>Follower: request action result
  Follower-->>Client: NotLeader with leader hint
  Client->>Leader: follow bounded redirect
  Worker->>Leader: register after endpoint rotation
  Leader-->>Worker: worker session
  Leader-->>Client: action result
Loading

Possibly related PRs

Suggested reviewers: copilot, poyrazk

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: client and worker failover across control-plane nodes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/raft-client-failover

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
crates/brokkr-worker/src/endpoint.rs (1)

91-93: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

completed_cycles as u32 truncates instead of saturating.

For absurdly large attempt values (the case the comment claims to defend against) the usize → u32 cast wraps, so the shift exponent can land back in range and yield a small backoff rather than the cap. Practically unreachable, but the saturating intent is easy to make literal:

♻️ Saturating cast
-    let factor = 1u64
-        .checked_shl(completed_cycles as u32 - 1)
-        .unwrap_or(u64::MAX);
+    let exponent = u32::try_from(completed_cycles)
+        .unwrap_or(u32::MAX)
+        .saturating_sub(1);
+    let factor = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/brokkr-worker/src/endpoint.rs` around lines 91 - 93, Update the shift
calculation in the backoff logic around factor so the completed_cycles-to-u32
conversion saturates at u32::MAX instead of truncating on overflow, preserving
the intended u64::MAX cap for excessively large attempt values.
crates/brokkr-worker/src/worker.rs (1)

236-247: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale comment: it still explains the removed worker_endpoint/control_endpoint fields.

Lines 241‑242 and the derive_default_worker_endpoint/--worker-control note now describe config that no longer exists on WorkerConfig. Since cas_url is just plane.worker_url() again, the comment can shrink to the single fact that matters (CAS is reached over the worker port in split-port mode, issue #139), and worker_url can be reused rather than recomputed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/brokkr-worker/src/worker.rs` around lines 236 - 247, Update the CAS
URL setup around plane.worker_url() to reuse the existing worker_url value
instead of recomputing it, and replace the stale multi-port configuration
explanation with a concise comment stating that split-port mode reaches CAS
through the worker port (issue `#139`). Remove references to worker_endpoint,
control_endpoint, derive_default_worker_endpoint, and --worker-control.
crates/brokkr-sdk/src/client.rs (2)

266-310: 📐 Maintainability & Code Quality | 🔵 Trivial

Missing tracing spans on new hot-path async helpers.

reconnect_to and follow_redirect are new async functions invoked on every leader-redirect retry, but unlike every other new async fn in this diff they have no #[tracing::instrument].

As per coding guidelines, "Add a tracing span for any new RPC handler or async function on a hot path."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/brokkr-sdk/src/client.rs` around lines 266 - 310, The new hot-path
async helpers lack tracing spans. Add #[tracing::instrument] attributes to both
reconnect_to and follow_redirect, preserving their existing behavior and
avoiding sensitive authentication values in captured fields.

Source: Coding guidelines


231-264: 📐 Maintainability & Code Quality | 🔵 Trivial

connect_any can't do what brokkr-cli's connect_first already re-implements.

connect_any only calls Self::connect(...), so it has no way to select the first reachable endpoint when TLS or a bearer token is configured. brokkr-cli/src/main.rs::connect_first re-implements the identical "iterate endpoints, keep the last transport error" loop specifically to cover that matrix. Consider extending connect_any to accept Option<TlsConfig>/Option<bearer> (mirroring reconnect_to's matrix) so the CLI can call the SDK primitive instead of duplicating it.

♻️ Sketch: extend connect_any to cover the auth matrix
pub async fn connect_any<S: Into<String>>(
    endpoints: impl IntoIterator<Item = S>,
    tls: Option<TlsConfig>,
    bearer: Option<String>,
) -> Result<Self, ClientError> {
    let endpoints: Vec<String> = endpoints.into_iter().map(Into::into).collect();
    let mut last_error = None;
    for endpoint in &endpoints {
        let attempt = match (tls.clone(), bearer.clone()) {
            (Some(t), Some(b)) => Self::connect_with_tls_and_bearer(endpoint.clone(), t, b).await,
            (Some(t), None) => Self::connect_with_tls(endpoint.clone(), t).await,
            (None, Some(b)) => Self::connect_with_bearer(endpoint.clone(), b).await,
            (None, None) => Self::connect(endpoint.clone()).await,
        };
        match attempt {
            Ok(client) => return Ok(client),
            Err(e) => last_error = Some(e),
        }
    }
    Err(last_error.unwrap_or_else(|| ClientError::InvalidEndpoint("no control-plane endpoints configured".to_string())))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/brokkr-sdk/src/client.rs` around lines 231 - 264, Extend
Client::connect_any to accept optional TlsConfig and bearer credentials, and
select the corresponding connection method for each TLS/bearer combination on
every endpoint attempt. Preserve the existing ordered iteration, success return,
last-error propagation, and empty-endpoint fallback, then update brokkr-cli’s
connect_first caller to use this SDK method instead of duplicating the
connection loop.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/brokkr-control/src/scheduler.rs`:
- Around line 1313-1316: Move the newly added tests, including
not_leader_returns_the_result_uncached_and_counts_it, below the complete
disconnect test and its documentation. Restore the full “§16 DoD” doc comment to
the disconnect test and ensure
disconnect_reassigns_in_flight_job_to_another_worker receives only its intended
documentation.

In `@crates/brokkr-worker/src/worker.rs`:
- Around line 200-208: Update the attempt handling in the worker session loop so
end.registered resets backoff only when the session remained healthy for the
established minimum attached duration; otherwise increment attempt with
saturating_add(1). Preserve the existing immediate reconnect reset for genuinely
useful sessions while ensuring sessions that fail immediately after Register
continue through rotation_plan backoff.

---

Nitpick comments:
In `@crates/brokkr-sdk/src/client.rs`:
- Around line 266-310: The new hot-path async helpers lack tracing spans. Add
#[tracing::instrument] attributes to both reconnect_to and follow_redirect,
preserving their existing behavior and avoiding sensitive authentication values
in captured fields.
- Around line 231-264: Extend Client::connect_any to accept optional TlsConfig
and bearer credentials, and select the corresponding connection method for each
TLS/bearer combination on every endpoint attempt. Preserve the existing ordered
iteration, success return, last-error propagation, and empty-endpoint fallback,
then update brokkr-cli’s connect_first caller to use this SDK method instead of
duplicating the connection loop.

In `@crates/brokkr-worker/src/endpoint.rs`:
- Around line 91-93: Update the shift calculation in the backoff logic around
factor so the completed_cycles-to-u32 conversion saturates at u32::MAX instead
of truncating on overflow, preserving the intended u64::MAX cap for excessively
large attempt values.

In `@crates/brokkr-worker/src/worker.rs`:
- Around line 236-247: Update the CAS URL setup around plane.worker_url() to
reuse the existing worker_url value instead of recomputing it, and replace the
stale multi-port configuration explanation with a concise comment stating that
split-port mode reaches CAS through the worker port (issue `#139`). Remove
references to worker_endpoint, control_endpoint, derive_default_worker_endpoint,
and --worker-control.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e40b2a8-bf1a-491b-baab-fd846d3f487a

📥 Commits

Reviewing files that changed from the base of the PR and between 07b0446 and cfe886d.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • crates/brokkr-cli/src/main.rs
  • crates/brokkr-control/src/scheduler.rs
  • crates/brokkr-control/src/services/execution.rs
  • crates/brokkr-control/tests/common/mod.rs
  • crates/brokkr-control/tests/leader_redirect.rs
  • crates/brokkr-sdk/src/client.rs
  • crates/brokkr-sdk/src/lib.rs
  • crates/brokkr-sdk/src/redirect.rs
  • crates/brokkr-worker/src/endpoint.rs
  • crates/brokkr-worker/src/lib.rs
  • crates/brokkr-worker/src/main.rs
  • crates/brokkr-worker/src/worker.rs
  • docs/journal/phase-5.md

Comment on lines +1313 to +1316
/// Decision D1 (I9b): on a `--raft` follower the post-execution
/// action-cache write is refused with `NotLeader`. The action already ran,
/// so the build must **succeed** with the real result, flagged uncached and
/// counted — never fail after paying for a sandbox run.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The new tests were inserted inside an existing doc comment, splitting it.

Lines 1311‑1312 (/// §16 DoD: a worker that dies mid-job … disconnect A) now attach to not_leader_returns_the_result_uncached_and_counts_it, and the remainder (/// (simulated crash), confirm the job is re-dispatched to B… at Line 1433) becomes the opening of disconnect_reassigns_in_flight_job_to_another_worker's doc. Move the new tests below the disconnect test (or after its complete doc block) so both doc comments read correctly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/brokkr-control/src/scheduler.rs` around lines 1313 - 1316, Move the
newly added tests, including
not_leader_returns_the_result_uncached_and_counts_it, below the complete
disconnect test and its documentation. Restore the full “§16 DoD” doc comment to
the disconnect test and ensure
disconnect_reassigns_in_flight_job_to_another_worker receives only its intended
documentation.

Comment on lines +200 to +208
// A session that registered proves the cluster is reachable, so the
// next outage starts its search fresh with no delay. Without this
// reset a long-lived worker would inherit the backoff from an outage
// hours earlier and crawl through a failover it should sail through.
attempt = if end.registered {
0
} else {
attempt.saturating_add(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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A session that registers and then immediately fails resets the backoff, producing a hot reconnect loop.

attempt is reset to 0 whenever end.registered is true, and rotation_plan(_, 0) returns Duration::ZERO. A node that accepts Register but then drops the stream (or fails on stream/heartbeat right after) makes every iteration reset the counter, so the worker reconnects with no delay, indefinitely — the exact flapping case backoff exists for. Gate the reset on the session having actually been useful (e.g. minimum attached duration), not merely on registration.

🔒️ Sketch: reset only after a session that stayed up
+        let started = tokio::time::Instant::now();
         let end = run_session(&cfg, plane).await;
+        let stable = started.elapsed() >= MIN_STABLE_SESSION; // e.g. 10s
@@
-        attempt = if end.registered {
+        attempt = if end.registered && stable {
             0
         } else {
             attempt.saturating_add(1)
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/brokkr-worker/src/worker.rs` around lines 200 - 208, Update the
attempt handling in the worker session loop so end.registered resets backoff
only when the session remained healthy for the established minimum attached
duration; otherwise increment attempt with saturating_add(1). Preserve the
existing immediate reconnect reset for genuinely useful sessions while ensuring
sessions that fail immediately after Register continue through rotation_plan
backoff.

CI's `cargo clippy --workspace --all-targets --locked -- -D warnings` rejected
`clippy::items_after_test_module`: the `clone_status` helper sat after
`#[cfg(test)] mod tests` in `client.rs`, because the edit that introduced it
appended to the end of the file.

Worth recording why local verification missed it: cargo does **not re-emit**
diagnostics for crates it considers fresh, so a clippy run made right after
touching only `brokkr-worker` stayed silent about an error already present in
`brokkr-sdk`. A green local clippy proves nothing unless the crate was
actually re-checked.

Re-verified with CI's commands verbatim, after touching every crate root to
force a real re-check:

  cargo fmt --all --check                                        -> ok
  cargo clippy --workspace --all-targets --locked -- -D warnings -> 0
  cargo doc --workspace --no-deps --all-features --locked        -> 0
  cargo test --workspace --all-targets --locked                  -> only the
      6 known evil_seccomp_caps failures (real kernel required)
  cargo test --workspace --doc --exclude brokkr-proto --locked   -> 0

No behaviour change: the function is byte-identical, only relocated.
@jackthepunished
jackthepunished merged commit 832e86f into main Jul 30, 2026
7 checks passed
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.

2 participants