# Agent Coordination Protocols

This document defines mandatory coordination protocols to prevent conflicts when multiple agents work on the same repository.

## Work Item Claiming Protocol

**CRITICAL:** Before starting work on ANY issue or PR, agents MUST claim it to prevent conflicts.

### Constants

```python
CLAIM_DURATION_HOURS = 2          # Claims expire after 2 hours
HEARTBEAT_INTERVAL_MINUTES = 10   # Must send heartbeat every 10 minutes
CLAIM_PREFIX = "[CLAIM:"          # Claim comment prefix
HEARTBEAT_PREFIX = "[HEARTBEAT:"  # Heartbeat comment prefix  
RELEASE_PREFIX = "[RELEASE:"      # Release comment prefix
```

### Claiming a Work Item

```python
def claim_work_item(owner, repo, item_type, item_number, agent_name, session_id):
    """
    Claim exclusive access to a work item (issue or PR).
    
    Args:
        owner: Repository owner
        repo: Repository name
        item_type: "issue" or "PR"
        item_number: Issue or PR number
        agent_name: Name of the claiming agent
        session_id: Unique session identifier
        
    Returns:
        (success: bool, claim_id: str, error: str)
    """
    
    # Check if already claimed
    available, current_claim = is_item_available(owner, repo, item_number)
    
    if not available:
        return (False, None, f"{item_type} #{item_number} is already claimed by {current_claim['agent']}")
    
    # Generate claim ID
    claim_id = f"{agent_name}-{session_id}-{int(time.time())}"
    
    # Post claim comment
    claim_time = datetime.utcnow()
    expire_time = claim_time + timedelta(hours=CLAIM_DURATION_HOURS)
    
    claim_comment = f"""{CLAIM_PREFIX}{claim_id}]
Agent: {agent_name}
Session: {session_id}
Started: {claim_time.isoformat()}Z
Expires: {expire_time.isoformat()}Z
Status: ACTIVE

⚠️ This {item_type} is now exclusively claimed by the above agent.
Other agents must wait for the claim to expire or be released.
Claim automatically expires after {CLAIM_DURATION_HOURS} hours without heartbeat.

---
**Automated by CleverAgents Bot**
Agent: {agent_name}"""

    try:
        forgejo_create_issue_comment(owner, repo, item_number, claim_comment)
        print(f"[CLAIMED] {item_type} #{item_number} claimed with ID: {claim_id}")
        return (True, claim_id, None)
    except Exception as e:
        return (False, None, f"Failed to post claim: {str(e)}")
```

### Checking Availability

```python
def is_item_available(owner, repo, item_number):
    """
    Check if a work item is available for claiming.
    
    Returns:
        (available: bool, active_claim: dict or None)
    """
    
    # Get recent comments (last 48 hours to catch expired claims)
    since = (datetime.utcnow() - timedelta(hours=48)).isoformat() + "Z"
    comments = forgejo_list_issue_comments(owner, repo, item_number, since=since)
    
    active_claims = []
    releases = []
    heartbeats = {}
    
    # Parse all coordination comments
    for comment in comments:
        body = comment.get('body', '')
        
        if CLAIM_PREFIX in body:
            claim_data = parse_claim_comment(comment)
            if claim_data:
                active_claims.append(claim_data)
                
        elif HEARTBEAT_PREFIX in body:
            hb_data = parse_heartbeat_comment(comment) 
            if hb_data:
                claim_id = hb_data['claim_id']
                # Keep only latest heartbeat per claim
                if claim_id not in heartbeats or hb_data['time'] > heartbeats[claim_id]['time']:
                    heartbeats[claim_id] = hb_data
                    
        elif RELEASE_PREFIX in body:
            rel_data = parse_release_comment(comment)
            if rel_data:
                releases.append(rel_data)
    
    # Find active claims
    now = datetime.utcnow()
    for claim in active_claims:
        claim_id = claim['claim_id']
        
        # Check if explicitly released
        if any(r['claim_id'] == claim_id for r in releases):
            continue
            
        # Check if expired
        expire_time = claim['expires']
        last_heartbeat = heartbeats.get(claim_id, {}).get('time', claim['started'])
        
        time_since_activity = now - last_heartbeat
        
        if time_since_activity < timedelta(hours=CLAIM_DURATION_HOURS):
            # Claim is still active
            return (False, claim)
    
    # No active claims
    return (True, None)
```

### Sending Heartbeats

```python
def send_heartbeat(owner, repo, item_number, claim_id, status_update=None):
    """
    Send heartbeat to maintain claim on a work item.
    Should be called every HEARTBEAT_INTERVAL_MINUTES.
    
    Args:
        status_update: Optional status message (e.g., "Running tests...")
    """
    
    heartbeat_comment = f"""{HEARTBEAT_PREFIX}{claim_id}]
Time: {datetime.utcnow().isoformat()}Z  
Status: {status_update or "Working..."}

---
**Automated by CleverAgents Bot**"""
    
    try:
        forgejo_create_issue_comment(owner, repo, item_number, heartbeat_comment)
        return True
    except:
        print(f"[WARNING] Failed to send heartbeat for claim {claim_id}")
        return False
```

### Releasing a Claim

```python
def release_claim(owner, repo, item_number, claim_id, reason="completed"):
    """
    Explicitly release a claim on a work item.
    
    Args:
        reason: Why the claim is being released
                "completed" - Work finished successfully
                "failed" - Could not complete the work
                "timeout" - Taking too long
                "error" - Unexpected error occurred
    """
    
    release_comment = f"""{RELEASE_PREFIX}{claim_id}]  
Time: {datetime.utcnow().isoformat()}Z
Reason: {reason}

✓ This work item is now available for other agents.

---
**Automated by CleverAgents Bot**"""
    
    try:
        forgejo_create_issue_comment(owner, repo, item_number, release_comment)
        print(f"[RELEASED] Claim {claim_id} released: {reason}")
        return True
    except:
        print(f"[ERROR] Failed to release claim {claim_id}")
        return False
```

### Helper Parsers

```python
def parse_claim_comment(comment):
    """Parse a claim comment into structured data."""
    body = comment.get('body', '')
    if CLAIM_PREFIX not in body:
        return None
        
    try:
        # Extract claim ID
        claim_id = body[body.find(CLAIM_PREFIX) + len(CLAIM_PREFIX):body.find(']')]
        
        # Extract fields
        lines = body.split('\n')
        data = {'claim_id': claim_id}
        
        for line in lines:
            if line.startswith('Agent:'):
                data['agent'] = line.split(':', 1)[1].strip()
            elif line.startswith('Session:'):
                data['session'] = line.split(':', 1)[1].strip()
            elif line.startswith('Started:'):
                data['started'] = datetime.fromisoformat(line.split(':', 1)[1].strip().rstrip('Z'))
            elif line.startswith('Expires:'):
                data['expires'] = datetime.fromisoformat(line.split(':', 1)[1].strip().rstrip('Z'))
                
        return data
    except:
        return None


def parse_heartbeat_comment(comment):
    """Parse a heartbeat comment."""
    body = comment.get('body', '')
    if HEARTBEAT_PREFIX not in body:
        return None
        
    try:
        claim_id = body[body.find(HEARTBEAT_PREFIX) + len(HEARTBEAT_PREFIX):body.find(']')]
        
        lines = body.split('\n')
        for line in lines:
            if line.startswith('Time:'):
                time_str = line.split(':', 1)[1].strip().rstrip('Z')
                return {
                    'claim_id': claim_id,
                    'time': datetime.fromisoformat(time_str)
                }
    except:
        return None


def parse_release_comment(comment):
    """Parse a release comment."""
    body = comment.get('body', '')
    if RELEASE_PREFIX not in body:
        return None
        
    try:
        claim_id = body[body.find(RELEASE_PREFIX) + len(RELEASE_PREFIX):body.find(']')]
        return {'claim_id': claim_id}
    except:
        return None
```

## Concurrent Work Detection

```python
def detect_concurrent_work(owner, repo, pr_number):
    """
    Detect if other agents are actively working on the same PR.
    Used to coordinate work that might conflict.
    """
    
    # Check claims in last 30 minutes
    since = (datetime.utcnow() - timedelta(minutes=30)).isoformat() + "Z"
    comments = forgejo_list_issue_comments(owner, repo, pr_number, since=since)
    
    active_agents = []
    
    for comment in comments:
        if "[WORKING]" in comment.get('body', ''):
            # Extract agent info
            agent_match = re.search(r'Agent: (\S+)', comment['body'])
            work_match = re.search(r'Work type: (\S+)', comment['body'])
            
            if agent_match:
                active_agents.append({
                    'agent': agent_match.group(1),
                    'work_type': work_match.group(1) if work_match else 'unknown',
                    'time': comment['created_at']
                })
    
    return active_agents


def coordinate_pr_work(owner, repo, pr_number, agent_name, work_type):
    """
    Coordinate work on a PR with other agents.
    
    Work types that conflict:
    - "code-change" conflicts with "code-change"
    - "merge-attempt" conflicts with everything
    - "review" does not conflict with anything
    
    Returns: "proceed", "wait", or "abort"
    """
    
    active_agents = detect_concurrent_work(owner, repo, pr_number)
    
    if not active_agents:
        return "proceed"
    
    # Define conflict matrix
    CONFLICTS = {
        'code-change': ['code-change', 'merge-attempt'],
        'merge-attempt': ['code-change', 'merge-attempt', 'test-fix'],
        'test-fix': ['code-change', 'merge-attempt', 'test-fix'],
        'review': []  # Reviews don't conflict
    }
    
    conflicting_work = CONFLICTS.get(work_type, [])
    
    for agent in active_agents:
        if agent['work_type'] in conflicting_work:
            # Check how long they've been working
            work_duration = datetime.utcnow() - datetime.fromisoformat(agent['time'])
            
            if work_duration < timedelta(minutes=15):
                print(f"[COORDINATION] Waiting for {agent['agent']} to complete {agent['work_type']}")
                return "wait"
            else:
                # They've been working too long, might be stuck
                print(f"[COORDINATION] {agent['agent']} has been working for {work_duration}, proceeding anyway")
    
    return "proceed"
```

## Usage in Agents

### Example: Implementation Worker

```python
# At the start of work
def begin_work_on_issue(owner, repo, issue_number, agent_name, session_id):
    # Claim the issue
    success, claim_id, error = claim_work_item(
        owner, repo, "issue", issue_number, agent_name, session_id
    )
    
    if not success:
        print(f"[ABORT] Could not claim issue: {error}")
        return False
        
    # Set up heartbeat schedule
    last_heartbeat = time.time()
    
    try:
        # Do the work...
        while not work_complete:
            # Send heartbeat if needed
            if time.time() - last_heartbeat > HEARTBEAT_INTERVAL_MINUTES * 60:
                send_heartbeat(owner, repo, issue_number, claim_id, 
                             f"Implementing subtask {current_subtask}")
                last_heartbeat = time.time()
            
            # Continue work...
            
    finally:
        # Always release claim
        release_claim(owner, repo, issue_number, claim_id, 
                     "completed" if work_complete else "failed")
```

### Example: PR Work Coordination

```python
# Before making changes to a PR
def work_on_pr(owner, repo, pr_number, agent_name, work_type):
    # Check coordination
    action = coordinate_pr_work(owner, repo, pr_number, agent_name, work_type)
    
    if action == "wait":
        # Wait and retry
        print("[WAITING] Another agent is working on this PR")
        time.sleep(300)  # 5 minutes
        return work_on_pr(owner, repo, pr_number, agent_name, work_type)
        
    elif action == "abort":
        print("[ABORT] Cannot work on PR due to conflicts")
        return False
        
    # Proceed with work
    # Post working indicator
    forgejo_create_issue_comment(owner, repo, pr_number,
        f"[WORKING] Starting {work_type}\n" +
        f"Agent: {agent_name}\n" + 
        f"Work type: {work_type}\n\n" +
        "---\n**Automated by CleverAgents Bot**")
    
    # Do the work...
```

## Enforcement Rules

1. **No work without claims** - Agents MUST NOT start work without successfully claiming
2. **Respect existing claims** - Agents MUST check availability before claiming  
3. **Send regular heartbeats** - Claims without heartbeats expire and work may be lost
4. **Always release claims** - Use try/finally to ensure claims are released
5. **Coordinate conflicting work** - Check for concurrent agents on PRs

## System Watchdog Monitoring

The system-watchdog will monitor for:
- Work started without claims
- Expired claims with agents still working
- Missing heartbeats on active work
- Unreleased claims after work completion
- Agents ignoring existing claims