Multi-Session Coordination Guide
How to coordinate multiple LLM sessions (Claude, Codex, Gemini, Kimi) working on the same project using the Inter-Session Message Bus.
Overview
When multiple LLM sessions work on the same project simultaneously, they need coordination to avoid:
- File overwrites - Two sessions editing the same file
- Task duplication - Two sessions working on the same task
- Context blindness - Sessions unaware of each other's progress
The Inter-Session Message Bus (messaging.db) solves this with four mechanisms:
| Mechanism | Purpose | Table |
|---|---|---|
| Session Registry | Track active LLM sessions | session_registry |
| Pub/Sub Messaging | Broadcast task status updates | inter_session_messages |
| File Locks | Advisory file-level conflict prevention | file_locks |
| Task Claims | Exclusive task ownership | task_claims |
Quick Start
1. Register Your Session
/session-register
This registers the current LLM session in messaging.db with:
- LLM vendor and model (e.g., claude opus-4.6)
- Process ID and project
- Starts a background heartbeat thread (every 30s)
2. Check Who Else Is Working
/session-status
Shows all active sessions across all LLM vendors:
Cross-LLM Session Dashboard
==================================================
2 active sessions | 1 file lock | 1 task claim
Session: claude-29583
Vendor: claude (opus-4.6)
Project: PILOT
Task: H.13.7
Session: codex-41200
Vendor: codex (o3)
Project: PILOT
Task: J.27.4
3. Lock Files Before Editing
/session-conflicts --lock path/to/file.py
If the file is already locked by another session, you'll see a conflict warning.
4. Claim Tasks for Exclusive Ownership
# Via Python API
from scripts.core.session_message_bus import get_session_message_bus
bus = get_session_message_bus()
claimed = bus.claim_task("H.13.7", session_id="claude-29583")
# True if claimed, False if already claimed by another active session
Architecture
Database: messaging.db
Located at ~/.coditect-data/context-storage/messaging.db. Uses SQLite WAL mode for high concurrency.
messaging.db
├── session_registry # Active LLM sessions
├── inter_session_messages # Pub/sub channel messages
├── file_locks # Advisory file locks
└── task_claims # Exclusive task ownership
Why Not sessions.db?
messaging.db is a dedicated lightweight database (~50-130 KB) optimized for high-frequency concurrent access. Keeping it separate from sessions.db (which can grow to 18+ GB) ensures messaging operations stay fast.
Core Concepts
Session Registry
Every LLM session registers itself on start and unregisters on exit.
bus.register_session(
session_id="claude-29583",
llm_vendor="claude",
llm_model="opus-4.6",
project_id="PILOT",
pid=29583
)
Heartbeats: A background thread sends heartbeats every 30 seconds. Sessions that miss heartbeats beyond the stale timeout (default: 300s) are automatically cleaned up, along with their file locks and task claims.
Pub/Sub Messaging
Sessions can broadcast and receive messages on named channels:
# Publish a message
bus.publish("task_broadcast", {"task_id": "H.13.7", "action": "started"})
# Poll for new messages
messages = bus.poll("task_broadcast", since_id=0)
for msg in messages:
print(f"{msg.sender}: {msg.payload}")
Built-in channels:
| Channel | TTL | Purpose |
|---|---|---|
task_broadcast | 600s | Task status announcements |
file_conflict | 300s | File conflict warnings |
state | 300s | General state updates |
File Locks (Advisory)
File locks are advisory - they warn but don't block. Sessions should check before editing.
# Acquire lock
acquired = bus.lock_file("src/main.py", session_id="claude-29583")
if acquired:
# Safe to edit
pass
else:
# Another session holds the lock - coordinate!
pass
# Release when done
bus.unlock_file("src/main.py", session_id="claude-29583")
Behaviors:
- Same session can re-lock a file it already holds (re-entrant)
- Locks from unregistered/stale sessions can be taken over
unregister_session()releases all locks for that session
Task Claims (Exclusive)
Task claims provide exclusive ownership - only one active session can claim a task.
# Claim a task
claimed = bus.claim_task("H.13.7", session_id="claude-29583")
# Returns True if claimed, False if already claimed by another active session
# Release when done
bus.release_task("H.13.7", session_id="claude-29583")
Behaviors:
- Claiming automatically broadcasts a "started" message
- Releasing automatically broadcasts a "released" message
- Claims from stale sessions can be taken over
- Race condition safe (uses UNIQUE constraint + IntegrityError handling)
Cross-LLM Status
See what all LLM vendors are doing across the project:
status = bus.get_cross_llm_status()
for s in status:
print(f"{s['llm_vendor']} ({s['llm_model']}): task={s['task_id']}, claim={s['claimed_task']}")
Returns a list of dicts with session_id, llm_vendor, llm_model, project_id, task_id, pid, heartbeat_at, and claimed_task.
Common Workflows
Workflow 1: Two LLMs on Separate Tracks
Terminal 1 (Claude - Track H):
/session-register --task-id H.13.7
bus.claim_task("H.13.7")
bus.lock_file("scripts/core/session_message_bus.py")
# ... work on H.13 ...
bus.unlock_file("scripts/core/session_message_bus.py")
bus.release_task("H.13.7")
Terminal 2 (Codex - Track J):
/session-register --task-id J.27.4
bus.claim_task("J.27.4")
bus.lock_file("scripts/core/paths.py")
# ... work on J.27 ...
bus.unlock_file("scripts/core/paths.py")
bus.release_task("J.27.4")
No conflicts because they're working on different files and tasks.
Workflow 2: Conflict Detection
Terminal 1 (Claude):
bus.lock_file("src/shared.py")
# ... editing ...
Terminal 2 (Codex):
acquired = bus.lock_file("src/shared.py")
# acquired = False!
# Check who holds the lock:
locks = bus.get_file_locks()
# → claude-29583 holds src/shared.py
# Coordinate before proceeding
Workflow 3: Task Handoff
Terminal 1 (Claude - finishes H.13.6):
bus.release_task("H.13.6")
bus.broadcast_task("H.13.6", "completed", {"outcome": "success"})
Terminal 2 (Codex - picks up H.13.7):
messages = bus.poll("task_broadcast")
# Sees: H.13.6 completed
bus.claim_task("H.13.7")
# ... continues the work ...
Project Discovery Integration
The message bus integrates with discover_project() in paths.py. When multiple projects match the current working directory:
- Bus lock-out (Step 1a): Projects with active LLM sessions (from messaging.db) are filtered out
- Process detection (Step 1b): Fallback using pgrep/lsof for sessions not registered in the bus
- Activity ranking (Step 2): Remaining projects ranked by most recent activity
This prevents a new LLM session from accidentally selecting a project that another session is already working on.
Performance
Benchmarked with 10 concurrent sessions:
| Operation | p50 | p99 | Max |
|---|---|---|---|
| Publish message | 1.2ms | 41.6ms | 85ms |
| Poll messages | 0.1ms | 0.3ms | 1.2ms |
| Claim task | 0.8ms | 3.9ms | 12ms |
| Register session | 0.5ms | 2.1ms | 8ms |
Database size: ~128 KB with 500 messages and 10 sessions. Throughput: ~4,000 messages/second.
Troubleshooting
Session Shows as Stale
Cause: Heartbeat thread stopped (session crashed or context compacted).
Fix: Re-register with /session-register.
Lock Won't Release
Cause: Session that held the lock is gone. Fix: Register a new session and attempt to lock the file - stale locks are automatically taken over.
Task Already Claimed
Cause: Another active session holds the claim.
Fix: Check /session-status --claims to see who holds it, then coordinate.
Database Locked
Cause: Heavy concurrent access.
Fix: The bus uses busy_timeout=3000ms with automatic retry. If persisting, check for long-running transactions.
API Reference
SessionMessageBus Methods
| Method | Purpose |
|---|---|
register_session(session_id, llm_vendor, llm_model, project_id, pid) | Register a session |
unregister_session(session_id) | Unregister (releases locks and claims) |
heartbeat(session_id) | Update heartbeat timestamp |
start_heartbeat_thread(interval, session_id) | Start background heartbeat |
list_sessions(active_only) | List registered sessions |
publish(channel, payload, ttl_seconds) | Publish message to channel |
poll(channel, since_id, limit) | Poll messages from channel |
lock_file(file_path, session_id) | Acquire advisory file lock |
unlock_file(file_path, session_id) | Release file lock |
get_file_locks() | List all active file locks |
claim_task(task_id, session_id) | Claim exclusive task ownership |
release_task(task_id, session_id) | Release task claim |
get_task_claims() | List all active task claims |
broadcast_task(task_id, action, details) | Broadcast task status |
update_session_task(task_id, session_id) | Update session's current task |
get_cross_llm_status() | Get all sessions with claims |
stats() | Get bus statistics |
Factory Function
from scripts.core.session_message_bus import get_session_message_bus
# Uses default messaging.db path
bus = get_session_message_bus()
# Custom path
bus = get_session_message_bus(db_path=Path("/custom/path/messaging.db"))
Related
| Resource | Path |
|---|---|
| Python API | scripts/core/session_message_bus.py |
| Tests | scripts/tests/test_session_message_bus.py (35 tests) |
| ADR-160 | internal/architecture/adrs/ADR-160-inter-session-messaging.md |
| Setup | scripts/CODITECT-CORE-INITIAL-SETUP.py (Step 4) |
/session-register | commands/session-register.md |
/session-status | commands/session-status.md |
/session-conflicts | commands/session-conflicts.md |
Version: 1.0.0 Created: 2026-02-06 Author: Claude (Opus 4.6) Track: H (Framework Autonomy) Task: H.13.7.3