Skip to content

perf(consensus): overlap arbiter adjudication and revision on dissent rounds#157

Merged
antonbabenko merged 1 commit into
masterfrom
perf/consensus-parallel-adj-rev
Jun 23, 2026
Merged

perf(consensus): overlap arbiter adjudication and revision on dissent rounds#157
antonbabenko merged 1 commit into
masterfrom
perf/consensus-parallel-adj-rev

Conversation

@antonbabenko

Copy link
Copy Markdown
Owner

What

In runToConvergence (the provider-arbiter consensus loop behind the consensus MCP tool), the arbiter's adjudication and revision calls ran serially on every non-final round - two back-to-back external-model calls. This overlaps them, gated on peer dissent.

Why it's safe (data-dependency, verified against source)

  • buildAdjudicationPrompt(state, results) and buildRevisionPrompt(state, results) each read only the round's peer results + state.currentPlan. Neither consumes the other's output - buildRevisionPrompt reads the peers' verdicts/issues, not the arbiter's adjudicated verdict.
  • submitAdjudication never mutates currentPlan, so the revision prompt is identical whether built before or after it.
  • Same-provider concurrency is already exercised today (the blind pass runs arbiter.ask concurrently with the peer fan-out).

The dissent gate

checkConvergence cannot converge if any responding peer is non-APPROVE. So:

  • Peer dissent -> round is guaranteed non-final -> revision is definitely needed -> run adjudication ∥ revision concurrently (zero waste).
  • All peers approve -> round may converge -> run adjudication only; the rare all-approve-but-arbiter-blocks case falls back to a serial revision.

This avoids the trap of unconditional speculation (which would burn a discarded revision call on the common round-1-convergence happy path).

Outcome Before After
Converge round 1 (all approve) adj only adj only - no change, no waste
Dissent round (non-final) adj -> rev (serial) adj ∥ rev - one leg saved
All-approve but arbiter blocks (rare) adj -> rev adj -> rev (serial)

Net: identical external-call count to the serial loop in every outcome. Behavior-preserving (same verdict, same convergence round). Wall-clock win = one serial arbiter leg per dissent round, ~18-29% loop time on a 3-5 round review.

Each arbiter branch (ask + parse) is failure-isolated via the existing blind-pass Promise.resolve().then(...).then(v=>v,()=>null) wrapper, so a sync throw, rejected ask, or parse fault cannot reject Promise.all.

Scope

  • In: core/orchestrate.js runToConvergence (provider-arbiter path).
  • Out: host-driven consensus-step / live /consensus (Claude is the arbiter there; serialization is the command's step sequence, not external calls).

Tests

test/core-run-to-convergence.test.js: RC1-RC8 pass unchanged; added RC9 (used-revision propagation into the next round), RC10 (no revision call dispatched on an all-approve converging round), RC11 (sync-throw on the revision leg isolated, loop never rejects), RC12 (serial revision on the all-approve-but-arbiter-blocks path).

Test plan

  • node --test test/core-run-to-convergence.test.js - 12 pass, 0 fail
  • npm run check - 578 pass, 0 fail, typecheck clean

Provenance

Surfaced by a /ask-all performance review (7 models), then the plan was hardened through a /consensus review (converged round 2, medium confidence) - the dissent gate came out of that review, replacing a naive always-parallel-and-discard draft that taxed the happy path.

… rounds in runToConvergence

The multi-round consensus loop ran the provider arbiter's adjudication and
revision calls serially on every non-final round - two back-to-back external
model calls. Both depend only on the round's peer results + state.currentPlan,
not on each other (buildRevisionPrompt reads the peers' verdicts, not the
arbiter's; submitAdjudication never mutates currentPlan), so they can run
concurrently.

Gate the parallel revision on peer dissent: when any responding peer is
non-APPROVE the round cannot converge (checkConvergence requires everyApprove),
so the revision is guaranteed needed and adjudication || revision overlap for
free. On an all-approve round only adjudication runs - no speculative call is
burned on a round that may converge. Net: identical external-call count to the
serial loop in every outcome, one serial arbiter leg saved per dissent round
(~18-29% loop wall-time on a 3-5 round review). Behavior-preserving: same
verdict, same convergence round, same call count.

Each arbiter branch (ask + parse) is isolated via the blind-pass
Promise.resolve().then wrapper so a sync throw, rejected ask, or parse fault
cannot reject Promise.all.

Tests: RC9 (used-revision propagation), RC10 (no wasted call on all-approve
convergence), RC11 (sync-throw on the revision leg isolated), RC12 (serial
revision on the rare all-approve-but-arbiter-blocks path). RC1-RC8 unchanged.
@antonbabenko antonbabenko merged commit da18ab9 into master Jun 23, 2026
1 check passed
@antonbabenko antonbabenko deleted the perf/consensus-parallel-adj-rev branch June 23, 2026 17:01
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