Skip to content

fix(runner): conclude benign ErrNoValidDutiesToExecute as not_required, not failed - #2988

Open
momosh-ssv wants to merge 7 commits into
stagefrom
fix/2903-benign-no-duties-outcome
Open

fix(runner): conclude benign ErrNoValidDutiesToExecute as not_required, not failed#2988
momosh-ssv wants to merge 7 commits into
stagefrom
fix/2903-benign-no-duties-outcome

Conversation

@momosh-ssv

@momosh-ssv momosh-ssv commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What this fixes

Fixes #2903.

A committee can reach consensus while one of its operators ends up with nothing to submit. It's rare, but when it happens it's the node doing the right thing, not a bug. The decided value is only the BeaconVote, and each operator submits for its own local duty set, re-checked against its local CommitteeDutyGuard after deciding. There are exactly two ways every validator in that set can drop out mid duty: the share was removed or liquidated (committee.go#L94-L101), or a fresher duty for the same validator took over (committee_guard.go#L36-L44). Either way nothing is missed: removed validators shouldn't be submitted for, superseded duties are covered by the newer run, and peers that still hold the validator submit as usual. An operator with no duties for the slot never even starts the runner, so it can't land here. The node handled all this correctly, but reported it as a failure: operators saw a ⚠️ duty failed warning and a failed data point in the duty outcome metric, for something that wasn't a failure at all.

This PR makes the node report that situation for what it is: a duty that completed correctly with nothing to submit (not_required).

What changed

Duty outcome classification (committee runner):

  • Post-consensus: reaching quorum with no beacon objects to submit (every duty skipped as guard-invalid — the divergent-validator-sets case) now records not_required before the error-handling defer can mark it failed (recording an outcome twice is safe — only the first one counts).
  • Consensus phase: deciding with zero valid duties to sign previously recorded no outcome at all, which the watcher later reported as a false "stuck". It now records not_required as well.

Guards added in review (round 1):

  • The not_required classification only applies when the empty result is provably benign: expectedPostConsensusRootsAndBeaconObjects now surfaces per-validator construction / domain-data / signing-root errors when nothing could be built, so an all-construction-failure empty map concludes failed instead of masking a missed submission. Partial failures keep the old behavior (log and continue, so one validator's failure never blocks the others' submissions).
  • The consensus-phase branch re-checks ctx.Err() before concluding: a shutdown mid-signing also reaches it with zero counts, and an abandoned duty must record no outcome at all (mirroring markDutyFailed's context.Canceled filter).
  • Both new not_required terminals close the duty flow (EndDutyFlow + recordTotalDutyDuration) and log an Info completion line, matching every pre-existing not_required site.

Aggregator-committee runner — deliberately NOT reclassified (round 1):

The first version of this PR converted its empty-beacon-objects terminal to not_required too. That was wrong and is reverted: unlike the committee runner, its beacon objects are built purely from the decided value with every error surfaced, and a decided value with zero aggregators and zero contributors is rejected by AggregatorCommitteeConsensusData.Validate() before it can ever be stored as decided. Reaching that branch is an invariant violation — a consensus-integrity signal — so it stays failed, now with an explicit invariant-violation error.

The log line in the message queue:

When the benign situation occurs, the queue also printed an error-level log line (❗ could not handle message...) and marked the trace as an error. It's now a debug-level line with neutral wording, and the trace is marked OK. Operator-visible evidence moves to the runner's own Info completion line (benign cases) or the outcome watcher's warn (failure cases).

What did NOT change

  • Real failures are still reported as failed — beacon submission errors, signature reconstruction failures, all-construction-failure empty results, and the aggregator-committee invariant violation above.
  • The queue still drops the message and shuts down the runner on this sentinel, exactly as before.
  • No impact on consensus or on what gets submitted to the beacon node.

Note on runner state (raised in review): markDutyNotRequired sets State.Succeeded = true, like every pre-existing not_required site — so the converted terminals now flip real runner state where they previously didn't (hasDutySucceeded() gates message validation, and the flag serializes into GetStateRoot()). No reachable behavior change today: ConsumeQueue terminates the runner on the sentinel before another message can be processed, and the spec test suite passes unchanged — but that containment is load-bearing if the queue's terminal-drop behavior is ever softened.

Why not_required and not a new label

The issue asked for a decision here. not_required already exists and means exactly this ("completed correctly with nothing to submit") — it's what the aggregator runner uses when a validator turns out not to be an aggregator. Reusing it avoids adding a new metric label for the same idea.

Testing

Six regression tests: the three original ones (benign post-consensus empty map, consensus-phase zero duties, and the aggregator-committee empty-decided-data terminal — now pinning failed), plus three from review round 1 (all-construction-failure empty map concludes failed, cancelled context concludes nothing, and the aggregator-committee invariant stays loud). Each fix was verified against the code it guards. The full runner and validator suites and the spectest suite (blst_enabled) pass.

…d, not failed

A committee or aggregator-committee duty that reaches post-consensus quorum
but leaves this operator with no beacon objects to submit is a normal
"nothing to do" terminal (divergent validator sets across the committee's
operators), yet both runners classified it as a failed outcome — emitting a
spurious "duty failed" warning and a false ssv.runner.duty.outcome=failed
data point, while the queue simultaneously treated the same sentinel as a
benign terminal drop.

Conclude the branch as not_required instead, in both runners:
- AggregatorCommitteeRunner: direct swap of markDutyFailed for
  markDutyNotRequired at the len(beaconObjects) == 0 branch.
- CommitteeRunner: markDutyNotRequired before the sentinel return; the
  deferred markDutyFailed becomes a no-op via concludeDuty idempotency.
- CommitteeRunner.ProcessConsensus: the zero-valid-duties sentinel had no
  marker at all (false "stuck"); conclude it as not_required too.

The sentinel is still returned in every case, preserving committee_queue's
terminal-drop handling. Genuine terminal failures (expected-roots errors,
terminal BLS reconstruction, submit failures) remain classified failed.

Closes #2903
@momosh-ssv
momosh-ssv requested review from a team as code owners August 11, 2026 13:09
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.2%. Comparing base (48d4f3a) to head (e4be291).

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes zero-work committee-runner terminals from failed or unconcluded to not_required while preserving ErrNoValidDutiesToExecute for queue termination.

  • Marks zero valid consensus duties as not_required.
  • Reclassifies empty post-consensus object sets for committee and aggregator-committee runners.
  • Adds regression coverage for all three paths.

Confidence Score: 4/5

The PR should not merge until the committee post-consensus path distinguishes benign absence of duties from failures that prevent every beacon object from being constructed.

CommitteeRunner can swallow all per-validator construction or beacon-data errors into an empty object set, and the changed branch then records that missed submission as a successful not_required outcome.

Files Needing Attention: protocol/v2/ssv/runner/committee.go

Important Files Changed

Filename Overview
protocol/v2/ssv/runner/committee.go Adds not_required conclusions to zero-duty and empty-object paths, but the latter also masks cases where all object-building operations fail.
protocol/v2/ssv/runner/aggregator_committee.go Reclassifies a genuinely empty aggregator/contributor assignment as not_required while retaining propagated derivation errors as failures.
protocol/v2/ssv/runner/committee_postconsensus_classification_test.go Covers guard-invalidated empty-object and zero-duty outcomes but not an empty result caused by object-construction or beacon-data failures.
protocol/v2/ssv/runner/aggregator_committee_test.go Verifies that decided data with no aggregator or contributor assignment concludes not_required and preserves the queue sentinel.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Consensus decides] --> B{Valid local duties?}
  B -->|No| C[Conclude not_required]
  B -->|Yes| D[Collect post-consensus quorum]
  D --> E[Build beacon objects]
  E --> F{Objects produced?}
  F -->|Yes| G[Submit to beacon node]
  F -->|No| C
  C --> H[Return ErrNoValidDutiesToExecute]
  H --> I[Committee queue terminates runner]
Loading

Reviews (1): Last reviewed commit: "fix(runner): conclude benign ErrNoValidD..." | Re-trigger Greptile

Comment thread protocol/v2/ssv/runner/committee.go Outdated
Comment on lines +542 to +544
// the deferred markDutyFailed becomes a no-op. The sentinel still tells committee_queue to
// drop the message and terminate the runner.
r.markDutyNotRequired()

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.

P1 Empty objects mask construction failures

When every validator is skipped because duty validation, object construction, domain-data retrieval, or signing-root computation fails, this branch records the empty result as successful not_required, suppressing the failed outcome and warning for a missed submission.

Knowledge Base Used: Protocol v2 Duty Runners and QBFT Consensus

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c92bad9.

The committee_queue case that catches ErrNoValidDutiesToExecute logged
"❗ could not handle message, dropping message and terminating
committee-runner" at Error level and marked the trace span as Error —
the same false alarm as the failed outcome classification, fired by the
same benign trigger. The runner now concludes the duty as not_required
on this sentinel, so the drop is a correct completion: log it at Debug
with neutral wording and set the span status to Ok. The drop-and-
terminate behavior is unchanged.
// not_required — not failed — before returning the sentinel; concludeDuty is idempotent, so
// the deferred markDutyFailed becomes a no-op. The sentinel still tells committee_queue to
// drop the message and terminate the runner.
r.markDutyNotRequired()

@iurii-ssv iurii-ssv Aug 11, 2026

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.

Building on the existing P1 by Greptile from above (empty objects masking construction failures) rather than repeating it — two clarifications that should help decide the fix:

A template already exists in the sibling runner. AggregatorCommitteeRunner hits the same empty-objects terminal but deliberately surfaces construction/domain errors instead of swallowing them, so a genuine failure stays failed rather than becoming not_required — see the note at aggregator_committee.go#L1441-L1445. Here, expectedPostConsensusRootsAndBeaconObjects instead does logger.Debug(...); continue on every construct / DomainData / signing-root failure, so the two siblings disagree on what an empty map means. Returning an error when a validator is skipped for a non-benign reason (vs. guard-invalid) would realign them and preserve failed.

Severity is likely below P1 in practice. DomainData for this domain/epoch is already fetched successfully during consensus-phase signing (signBeaconObject) and is normally cached, so an all-validators failure surfacing only at post-consensus is an unlikely path. The guard-invalid case (divergent validator sets — the actual #2903 trigger) is the dominant real cause of an empty map.

Scope note: only this post-consensus branch is affected. The consensus-phase sibling change at L381-L387 is unambiguous — totalAttesterDuties is incremented before signing and signing errors return early via errCh, so a zero count there can only mean guard-invalid, never a swallowed failure.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c92bad9.
Went with the realignment you suggested, scoped to the empty-map terminal: expectedPostConsensusRootsAndBeaconObjects now collects the construct/DomainData/signing-root errors and surfaces them when nothing could be built, so that empty result concludes failed via the defer, while partial failures keep the per-validator debug-and-continue behavior (one validator's failure shouldn't block the others' submissions).

A guard-invalid-only empty map stays not_required. Added a regression test with DomainData failing after consensus to pin the all-failure path.

@ovidiu-ssv-labs ovidiu-ssv-labs 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.

The core idea is right and the CommitteeRunner consensus-phase fix (false 'stuck') is a clear win, but the AggregatorCommitteeRunner half reclassifies a state that is unreachable under spec-valid decided data — an invariant violation — as benign, removing its only alarm. Separately, the shutdown path can now emit a spurious not_required, the new terminals skip the duty-duration bookkeeping every sibling not_required site performs, and the change is not 'observability only' as described (markDutyNotRequired sets State.Succeeded). Greptile's P1 on committee.go:544 is confirmed still open at head. [verdict: with_fixes]

Finding 4 · [MINOR] Change is not "observability only": markDutyNotRequired also flips State.Succeededprotocol/v2/ssv/runner/runner.go:643

The PR description states this change has "no impact on consensus or on what gets submitted... observability only." That's not quite accurate: markDutyNotRequired sets b.State.Succeeded = true, unlike the replaced markDutyFailed (which doesn't) and unlike the previous ProcessConsensus site (which set nothing). All three converted sites now flip real runner state: hasDutySucceeded() gates ValidatePreConsensusMsg/ValidatePostConsensusMsg (now short-circuit instead of processing); hasDutyRunning() and friends feed ConsumeQueue's per-iteration state; and the flag is serialized into GetStateRoot(), which spec tests compare directly.

Verified blast radius: spec tests pass at head (go test -tags blst_enabled ./protocol/v2/ssv/spectest, no fixture reaches these branches, so no root moves) — but make full-test excludes spectest as a separate target, so the PR's claim that "the full runner and validator suites pass" would not have caught a spectest regression here. No message-gating bug is reachable today, but only because ConsumeQueue returns entirely on this sentinel — an implicit containment that becomes load-bearing if that behavior is ever softened.

// or contributors assigned to it in the decided data). Conclude as not_required (matching
// CommitteeRunner) — neither a false "stuck" nor a spurious "failed". The sentinel still
// tells committee_queue to drop the message and terminate the runner.
r.markDutyNotRequired()

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.

Finding 1 · [IMPORTANT] Don't reclassify AggregatorCommitteeRunner's empty-objects branch as benign — there it is an invariant violation, not a per-operator no-op

CommitteeRunner's "nothing to submit" rationale doesn't transfer to AggregatorCommitteeRunner. CommitteeRunner builds beaconObjects from LOCAL state (local DutyGuard, local construction), so divergent validator sets across operators genuinely produce an empty map on one operator only — benign. AggregatorCommitteeRunner instead builds beaconObjects purely from the DECIDED consensus data, byte-identical for every operator, surfacing every error rather than skipping any. So len(beaconObjects)==0 here can only happen if the decided value had zero Aggregators and zero Contributors — a state AggregatorCommitteeConsensusData.Validate() explicitly rejects, and validateDecidedConsensusData enforces before storing it as decided.

Reaching this branch therefore means the decided-value check was bypassed or regressed, QBFT decided on a value that never passed CheckValue, or there's a spec-level encode/decode mismatch — a consensus-integrity signal, not routine idle time. Before this PR it surfaced as a loud failure; after, it's a silent not_required + debug log. The node goes quiet on a state that should be structurally impossible.

Telling corroboration: the PR's own regression test for this branch can't reach it through the runner's normal flow — it hand-installs State.DecidedValue, bypassing CheckValue/Validate() to force the condition.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cba5938.
You're right, this one doesn't transfer: reverted the branch to failed with an explicit invariant-violation error (the sentinel stays wrapped so the queue still drops the message and terminates the runner), and the regression test now pins the failed outcome and documents why it has to hand-install the decided value.

// Benign terminal: the committee decided but this operator ended up with zero valid duties to
// sign. Conclude as not_required so the watcher doesn't report a false "stuck"; the sentinel
// still tells committee_queue to drop the message and terminate the runner.
r.markDutyNotRequired()

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.

Finding 2 · [MINOR] Guard the consensus-phase not_required against context cancellation (shutdown now records a benign outcome)

In ProcessConsensus, the zero-duties branch triggers not only when every duty is filtered by the DutyGuard, but also when the context is cancelled — neither the feeder nor the workers increment counters on cancellation, and nothing re-checks ctx.Err() before concluding not_required. A cancellation observed here means the parent context (node/committee shutdown) was cancelled.

This matters because the codebase deliberately treats cancellation as "not attempted", never as an outcome: markDutyFailed explicitly filters context.Canceled, with a comment noting a cancelled duty was abandoned, not attempted-and-failed. markDutyNotRequired has no equivalent filter, so a shutdown mid-signing now produces a positive duty.outcome=not_required data point claiming the duty completed correctly — even though whether it lands at all is non-deterministic (watchDutyOutcome picks pseudo-randomly between the conclusion and ctx.Done() when both fire together).

On a rolling restart across a large fleet, this shows up as a burst of spurious not_required outcomes for committee duties that were simply abandoned mid-flight — exactly the kind of noise this PR is trying to remove from the metric.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b0ef6a9.
The zero-counts branch now re-checks ctx.Err() and returns the cancellation without concluding any outcome, mirroring markDutyFailed's context.Canceled filter.

Added a cancelled-context regression test.

// not_required — not failed — before returning the sentinel; concludeDuty is idempotent, so
// the deferred markDutyFailed becomes a no-op. The sentinel still tells committee_queue to
// drop the message and terminate the runner.
r.markDutyNotRequired()

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.

Finding 3 · [MINOR] New not_required terminals skip EndDutyFlow/recordTotalDutyDuration and leave no operator-visible completion line

Every pre-existing markDutyNotRequired() call site pairs the conclusion with duty-flow bookkeeping (EndDutyFlow + recordTotalDutyDuration) and usually an Info completion log. The three new sites this PR adds (committee.go:385, committee.go:544, aggregator_committee.go:894) call the marker alone.

Two consequences. First, metric skew: ssv.runner.duty.outcome{not_required} increments with no matching total-duty-duration sample, and the duty-flow measurement is left open — any dashboard reconciling outcome counts against the duration histogram shows a permanent, unexplained gap. Second, zero operator-visible evidence: before this PR the operator saw duty failed plus an error-level queue log; after, the metric is benign, the runner itself logs nothing, and a related change in this PR downgrades the queue's own log line to Debug. A committee runner terminating early for the slot becomes invisible at default log level — which matters independently of whether the classification itself is correct, since it's the operator's only handle if that classification turns out wrong.

@momosh-ssv momosh-ssv Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 82e1c78.
Both committee sites now close the duty flow (EndDutyFlow + recordTotalDutyDuration) and log an Info completion line, matching the pre-existing not_required sites.

The aggregator-committee site went back to failed in cba5938, so operator visibility there comes from the outcome watcher's warn.

…uilt, instead of not_required

An empty post-consensus beacon-objects map was concluded not_required
unconditionally, but expectedPostConsensusRootsAndBeaconObjects swallows
per-validator construction / domain-data / signing-root failures with a
debug log, so an all-validators failure produced the same empty map and
recorded a missed submission as a benign completion.

Collect the per-validator errors and surface them when nothing could be
built (partial failures keep the debug-and-continue behavior), aligning
with the sibling AggregatorCommitteeRunner which surfaces these errors.
The empty-map not_required terminal is now guaranteed benign
(guard-invalid skips only).
…inal loud (failed)

Reverts the not_required reclassification for this runner. Unlike the
sibling CommitteeRunner (whose beacon objects come from local state, so
divergent validator sets legitimately empty them on one operator), this
runner builds beaconObjects purely from the decided value with every
error surfaced — an empty map means the decided data had zero
aggregators and zero contributors, which
AggregatorCommitteeConsensusData.Validate() rejects before a value can
be stored as decided. Reaching the branch is an invariant violation
(bypassed or regressed decided-value validation), a consensus-integrity
signal that must stay a warning, not become a silent benign outcome.

The sentinel stays wrapped in the returned error so committee_queue
still drops the message and terminates the runner.
… phase is cancelled

The zero-duties branch in CommitteeRunner.ProcessConsensus also fires on
context cancellation: the duty feeder and workers bail out on ctx.Err()
before incrementing any counter, so a node shutdown mid-signing recorded
a spurious not_required outcome for a duty that was abandoned, not
completed. Re-check ctx.Err() before concluding, mirroring
markDutyFailed's context.Canceled filter — cancellation is never an
outcome.
… bookkeeping and a completion log

Every pre-existing markDutyNotRequired site closes the duty flow
(EndDutyFlow + recordTotalDutyDuration) and logs an Info completion
line; the two new committee terminals called the marker alone, skewing
outcome counts against the duration histogram and leaving no
operator-visible evidence of the early termination at default log
level (the queue's own line is Debug).
@momosh-ssv

Copy link
Copy Markdown
Contributor Author

Re Finding 4: fair catch — markDutyNotRequired does set State.Succeeded, so "observability only" overstated it. I've rewritten the PR description to drop that claim and describe the state effect and the ConsumeQueue containment explicitly, and this round was validated against the spectest suite as well (passes), since full-test doesn't run it. Findings 1–3 are addressed in cba5938, b0ef6a9 and 82e1c78.

@y0sher

y0sher commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

I don't see this as something showing up in the spec, so I guess it's ok to do our own extra err handling. see ref: https://github.com/ssvlabs/ssv-spec/blob/main/ssv/aggregator_committee.go

Fyi @GalRogozinski @MatheusFranco99

but I don't really understand how this line is possible, and need some more explanation -
Sometimes a committee reaches consensus but one of its operators ends up with nothing to submit to the beacon node. This is normal — it happens when the operators in a committee don't run the exact same set of validators.

@MatheusFranco99

Copy link
Copy Markdown
Contributor

I don't see this as something showing up in the spec, so I guess it's ok to do our own extra err handling. see ref: https://github.com/ssvlabs/ssv-spec/blob/main/ssv/aggregator_committee.go

but I don't really understand how this line is possible, and need some more explanation -
Sometimes a committee reaches consensus but one of its operators ends up with nothing to submit to the beacon node. This is normal — it happens when the operators in a committee don't run the exact same set of validators.

Yep, agree, ok to add err handling here. But I also don't quite understand the line.

Line I thought:

  1. Operators 1, 2, 3 and 4.
  2. Let's say aggregator duties are for validators: A {a1, a2, a3, ...} + B {b1, b2, ...}. A validators are known to everyone, but B validators are new and only operators 2, 3 and 4 know about it.
  3. Only validators B really have a duty.
  4. They run consensus, operator 1 pretty much doesn't participate and finishes.

Would that be it?

Operators skip part of the proposals for validators they don't know but Idk what is the condition for when it doesn't know any of them...
It will surelly terminate due to the commit quorum, but idk how the timeline looks.

@y0sher

@momosh-ssv

momosh-ssv commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@y0sher @MatheusFranco99 backing that sentence with the concrete code paths. The empty terminal is reachable in two ways, both benign:

  1. Validator removed or liquidated while the duty is in flight: onShareStop calls RemoveShare, which calls dutyGuard.StopValidator. Operators process contract events at their own pace, so one operator can drop the validator mid duty while the rest carry the duty to quorum.
  2. A fresher duty supersedes the running one: CommitteeDutyGuard.StartDuty at a higher slot invalidates the running duty for the same validator. Sync committee validators have a duty every slot, so any consensus that crosses a slot boundary hits this.

Both are per operator because the decided value is only the BeaconVote; after deciding, each operator re-checks its own local duty set against its own guard (committee.go#L305, committee.go#L1053).

@MatheusFranco99 the zero known validators case cannot reach this branch: prepareDuty fails with "no shares for duty's validators" and the runner never starts, so nothing is recorded on that operator. The branch requires starting with at least one validator and losing all of them mid duty.

One scope note: since cba5938 the not_required classification covers the committee runner only. The aggregator committee empty branch stays failed, per Finding 1 above.

The PR description now states these two triggers explicitly.


Quick follow-up after some internal back and forth on the "this is normal" wording. Better said: it's rare, but when it happens nothing actually went wrong.

Rare because it takes every validator in the operator's local duty set getting invalidated mid duty, and there are only two ways that happens: the share was removed (committee.go#L94-L101) or a fresher duty took over (committee_guard.go#L36-L44).

Nothing gets missed either way: removed validators shouldn't be submitted for, superseded duties are covered by the newer run, and the rest of the committee still forms quorum without us, same as if we were offline.

One more idea that came up: an operator briefly on the wrong fork with no duty for the slot. That can't reach this branch, the runner just never starts (committee.go#L252-L255). And with a partial duty set it submits whatever it has (committee.go#L1053-L1056).

Since c92bad9 this isn't just an argument, the code enforces it: if the map is empty because building objects failed, we error out and conclude failed (committee.go#L1126-L1128). Only guard invalidations can leave it empty and benign.

Updated the PR description to say "rare but expected" instead of "this is normal".

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.

5 participants