agent-teams: concurrent-session split-brain — turn-injection routing non-deterministic when multiple sessions share a team_name
Summary
When two or more Claude Code sessions in the same project root both have the same team_name open (e.g., one tab opened from a handoff while a previous-day tab is still alive in another tab), both sessions attach to the same ~/.claude/teams/{team_name}/config.json. The filesystem-level artifacts (inbox JSON, task list, config members) are visible to both. But conversation-turn injection for SendMessage to "team-lead" is non-deterministic across the attached sessions — sometimes one receives it as a turn, sometimes the other, sometimes both with lag, sometimes neither (the inbox file always shows the message correctly persisted in all cases).
leadSessionId in config.json is NOT the routing key. Empirically corroborated by both sessions independently via cross-tab handshake.
This breaks the contract documented in the TeamCreate / Agent tool descriptions:
"Messages from teammates are automatically delivered to you. You do NOT need to manually check your inbox."
Steps to reproduce
- Tab 1:
TeamCreate({ team_name: "split-test" })and spawn an agent.SendMessageworks as expected here. - Tab 2 in the same project root:
TeamCreate({ team_name: "split-test" })— succeeds silently with no warning that the team already exists.membersarray picks up the new session as another team-lead-capable participant. - From either tab: spawn another teammate and have it
SendMessage to "team-lead"with a text payload. - The message lands in
~/.claude/teams/split-test/inboxes/team-lead.json. It appears as a conversation turn in one of the tabs, non-deterministically. Sometimes both. Sometimes neither. - The non-receiving tab has no way to know without manually polling the inbox file.
Expected behavior
Either:
- (a)
TeamCreateerrors when an active session is already attached to the team: "Active session X is using this team — close it or pick a different name", OR - (b) Routing is deterministic — e.g., always to
leadSessionId, or via explicit "lead handoff" semantics.
In both cases, behavior should match the documented contract that messages "are automatically delivered" — without depending on which of multiple tabs the user is currently looking at.
Actual behavior
- Two sessions can both clamp the team-lead role.
- Turn-injection routing splits between them non-deterministically.
- The session that doesn't receive a given turn has no signal that anything is missing — silence at the conversation layer is indistinguishable from "no messages sent".
- Each session's orchestrator sees roughly half the picture and can independently form contradictory hypotheses about what's happening (in our case: "delivery dropped" vs "delivery delayed" — both wrong).
Evidence (controlled experiment, 2026-05-06)
Empirical data from a four-round experiment + cross-tab handshake. All inbox timestamps in UTC. The same physical specialist (cypress-specialist@qa-integration) sent identical PINGs across rounds.
From session A (the "new tab", which was leadSessionId per config):
| Round | Inbox entry | Reached this session as a turn? |
|-|-|-|
| R1 | PING-R1-CYPRESS @ 16:01:29 | ❌ |
| R1 | PING-R1-MINIKUBE @ 16:01:34 | ❌ |
| R2 | PING-R2-CYPRESS-SOLO @ 16:03:00 | ❌ |
| R3 | PING-R3-CYPRESS-PULSE-1 @ 16:14:52 | ❌ |
| R3 | PING-R3-CYPRESS-PULSE-2 @ 16:15:07 | ❌ |
| R4 | PING-R4-GENERAL-PURPOSE @ 16:17:xx | ❌ |
| handshake | cross-tab handshake from session B (which session B sent via TaskCreate, side-channel) | ✅ via TaskList only |
Session A: 0/6 text payloads as turns. Initially read as "automatic delivery is broken".
From session B (the "other tab", which was NOT leadSessionId per config — corroborated by their own state dump):
| Round | Inbox entry | Reached this session as a turn? |
|-|-|-|
| R1 | PING-R1-CYPRESS | ✅ immediate |
| R1 | PING-R1-MINIKUBE | ✅ immediate |
| R2 | PING-R2-CYPRESS-SOLO | ✅ immediate |
| R3 | PING-R3-CYPRESS-PULSE-1 | ✅ immediate |
| R3 | PING-R3-CYPRESS-PULSE-2 | ⚠️ delayed ~3 min then arrived |
| R4 | PING-R4-GENERAL-PURPOSE | ✅ immediate |
| handshake | cross-tab handshake from session A | ✅ immediate (this is how we discovered each other) |
Session B: 5/6 immediate, 1 delayed.
leadSessionId per config pointed to session A. But session B (not the lead) was actually receiving the turn injections. Session B's own state dump:
"Despite NOT being leadSession, I'm receiving specialist text payloads as turn injections."
How we discovered the actual mechanism
The two sessions did not know about each other initially. Session A spent three experiment rounds testing hypotheses within the wrong frame ("delivery is dropped" or "delivery is delayed"). The user's intuition broke the frame: "could it be Plan mode? could you have another tab open?" Spawning a subagent_type: "general-purpose" test agent (Round 4) surfaced the second tab when the user noticed the OTHER tab was acknowledging the test agent's PING. Direct cross-tab handshake via SendMessage to "team-lead" from session A landed as a turn in session B, confirming concurrent attachment. Session B replied via TaskCreate (the shared task list), enabling reliable side-channel coordination. Session B's state dump corroborated the diagnosis with their own PING reception log.
UX gaps highlighted
TeamCreateis silent when a config already exists. It should warn: "Team already in use by session X. Override, join, or pick a different name?"- No tool exposes which sessions are currently attached to a team.
leadSessionIdlooks authoritative but isn't — it's not the routing key. - Routing for shared role names like
team-leadis opaque. When multiple sessions share a role, the user has no way to predict which one will receive a given turn. - The shared inbox JSON file is the only reliable channel during split-brain, but the documented "automatic delivery" guidance steers users away from inspecting it.
- Lifecycle side-effect (related): if
shutdown_requestis sent to a non-responsive specialist AND a fresh spawn is initiated for the same role, the framework auto-replaces the specialist AND resets the team task counter to ID 1. Distinct from a cleanshutdown_response— and easy to mistake for "fresh team state" without explicit cleanup.
Workaround during split-brain
Once detected, sessions can coordinate cleanly:
SendMessage to "team-lead"from one session lands as a turn in the other (because routing is non-deterministic but functional in the cross-tab direction).- The receiving session replies via
TaskCreate— task list is shared, no routing dependency. - The user picks which session continues; the retiring session does graceful
shutdown_requestafter writing any non-redundant state to a finalTaskCreate"STATE DUMP" task.
This pattern is empirically validated 2026-05-06 in the same session that produced this report.
Suggested resolution directions
- Option A —
TeamCreateerrors when another active session is detected: "Active session X is using this team — close it or pick a different name." - Option B — Make routing deterministic, either always to
leadSessionIdor via explicit "lead handoff" semantics. - Option C — At minimum, document the multi-tab caveat clearly in agent-teams docs ("only one session per
team_nameat a time across all open Claude Code tabs").
Environment
- Claude Code CLI on macOS, working inside a TypeScript/Node.js monorepo workspace.
- Model: Claude Opus 4.7 (
claude-opus-4-7) for both team-lead sessions and teammates. - Teammates spawned with
subagent_type: "Plan"(cypress-specialist, minikube-specialist) andsubagent_type: "general-purpose"(delivery-test). All experienced the same split-brain symptom —subagent_typeis not a factor. run_in_background: true.- Date observed: 2026-05-06.
Suggested triage area
agent-teams lifecycle + SendMessage routing. The symptom is at the conversation-turn injection layer; the inbox file persistence layer works correctly across both sessions.
---
Note: this issue's title and body were updated 2026-05-06 to reflect the corrected diagnosis (concurrent-session split-brain, not "delivery dropped"). The original framing was based on incomplete data from a single session; the cross-tab handshake revealed the actual mechanism. GitHub preserves the edit history.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗