Skip to content

Bug: ConsensusTool concurrent callers overwrite each other's proposal state (singleton mutable state) #395

@jp63670

Description

@jp63670

Project Version

9.5.0

Bug Description

ConsensusTool uses mutable instance-level state (self.original_proposal, self.models_to_consult, self.accumulated_responses) for its multi-step workflow. Because the tool is instantiated once as a singleton in server.py (line ~240: TOOLS = {"consensus": ConsensusTool(), ...}), all callers share the same state.

This is fine for a single sequential caller. However, when multiple callers invoke consensus concurrently — for example, Claude Code Agent Teams where multiple sub-agents each run their own consensus debate in parallel — the shared state gets overwritten between callers.

What happens

  1. Agent A calls consensus step 1 → sets self.original_proposal = "Evaluate foundation candidates..."
  2. Agent B calls consensus step 1 → overwrites self.original_proposal = "Evaluate neutral candidates..."
  3. Agent A calls consensus step 2 → _consult_model reads self.original_proposal and sends Agent B's proposal to Agent A's models
  4. Models 2-5 in Agent A's debate see the wrong context

Affected state in tools/consensus.py

# These are set in __init__ and mutated in execute_workflow — shared across all callers
self.original_proposal: str | None = None
self.models_to_consult: list[dict] = []
self.accumulated_responses: list[dict] = []

Root cause

_consult_model() reads self.original_proposal (line ~680):

prompt = self.original_proposal if self.original_proposal else self.initial_prompt

This is the correct proposal for the last caller who set step 1, but wrong for any earlier caller still mid-workflow.

Suggested Fix

Key the workflow state by continuation_id instead of storing it on self:

def __init__(self):
    super().__init__()
    # ... existing fields kept for backward compat ...
    self._sessions: dict[str, dict] = {}  # keyed by continuation_id

# In step 1:
self._sessions[continuation_id] = {
    "original_proposal": request.step,
    "models_to_consult": request.models or [],
    "accumulated_responses": [],
}

# In step 2+:
session = self._sessions[continuation_id]
prompt = session["original_proposal"]  # isolated per-caller

The continuation_id mechanism already exists in the protocol — it just needs to be used as the isolation key for workflow state. Callers generate a UUID and pass it on every step. Sessions are cleaned up on workflow completion.

This is backward compatible: callers without continuation_id fall through to the existing instance-level state (single-caller behavior unchanged).

Relevant Log Output

No crash — the bug is silent. Models receive the wrong prompt and produce responses for the wrong topic. Only detectable by inspecting model output content.

Operating System

macOS

Reproduction

  1. Run two consensus workflows concurrently (e.g., via Claude Code Agent Teams spawning multiple sub-agents)
  2. Each uses different models arrays and different step prompts
  3. Observe that models in the first workflow receive the second workflow's proposal text

Impact

This makes consensus unusable in any concurrent/parallel agent scenario — which is increasingly common with Claude Code Agent Teams, n8n workflows, and multi-agent orchestration patterns.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions