Bug: ConsensusTool concurrent callers overwrite each other's proposal state (singleton mutable state)
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
- Agent A calls
consensusstep 1 → setsself.original_proposal = "Evaluate foundation candidates..." - Agent B calls
consensusstep 1 → overwritesself.original_proposal = "Evaluate neutral candidates..." - Agent A calls
consensusstep 2 →_consult_modelreadsself.original_proposaland sends Agent B's proposal to Agent A's models - 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_promptThis 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-callerThe 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
- Run two
consensusworkflows concurrently (e.g., via Claude Code Agent Teams spawning multiple sub-agents) - Each uses different
modelsarrays and differentstepprompts - 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.
Source: BeehiveInnovations/pal-mcp-server