---
last-updated: 2026-02-22
status: stable
related-tasks: loom-97537124, loom-1b898dea
---

# Merge Agent

The merge agent is an automated subsystem within Loom that handles merging feature branches back into the main branch after an agent completes work in a git worktree. It provides distributed locking, post-merge testing, conflict detection with auto-resolution, and full worktree cleanup -- all without human intervention.

## Overview

When multiple AI agents work in parallel on separate git worktrees, their completed branches must be merged back into `main` sequentially to avoid corruption. The merge agent serializes these merges using a Redis distributed lock, runs the test suite after each merge, and pushes to origin on success. If anything goes wrong -- merge conflict, test failure, or git error -- the merge agent rolls back and marks the task as failed with diagnostic details.

### Why It Exists

Without the merge agent, every agent completing work would need to:
1. Coordinate with other agents to avoid concurrent merges
2. Run tests post-merge
3. Handle conflicts and rollback on failure
4. Clean up worktrees and branches

The merge agent centralizes this logic into a single, reliable pipeline that runs as a background asyncio task dispatched by the orchestrator.

## How Merge Tasks Are Created

Merge tasks are created atomically inside `store.complete_task()` when an agent calls `loom_done` with a `branch_name` parameter.

### Step-by-Step Flow

1. **Agent calls `loom_done(task_id, output, branch_name="worktree-agent-abc123")`**
   - Defined in `loom/mcp/tools.py`

2. **`store.complete_task()` runs inside a single Postgres transaction:**
   - Validates the task is in `claimed` status
   - Updates the task to `done` with the provided output
   - When `branch_name` is not None, calls `_create_merge_task_in_txn()` within the same transaction

3. **`_create_merge_task_in_txn()` creates the merge task with these fields:**

   | Field | Value |
   |-------|-------|
   | `id` | `loom-{8 hex chars}` (fresh ID) |
   | `project_id` | Same as parent task |
   | `title` | `"Merge branch {branch_name}"` |
   | `status` | `pending` |
   | `priority` | `p0` (highest -- merges should not wait) |
   | `context` | `{"branch_name": "...", "parent_task_id": "...", "worktree_path": "..."}` |
   | `done_when` | `"Branch {branch_name} merged to main and worktree cleaned up"` |

   The `worktree_path` is computed as `.claude/worktrees/{branch_name minus "worktree-" prefix}`.

4. **Back in `loom_done`**, after the transaction completes:
   - The merge task is synced to Redis cache
   - Added to the ready queue (sorted set, scored at p0 = 300)
   - A `task.created` event is published
   - The merge task dict is included in the response under `resp["merge_task"]`

### Source References

- `loom/mcp/tools.py` -- `loom_done()` tool coordinator
- `loom/graph/store.py` -- `complete_task()` and `_create_merge_task_in_txn()`

## Sequence Diagram

```mermaid
sequenceDiagram
    participant WA as WorktreeAgent
    participant LD as loom_done
    participant TS as TaskStore (Postgres)
    participant EB as EventBus (Redis)
    participant OR as Orchestrator
    participant MA as MergeAgent
    participant Git as Git (main)
    participant PT as PyTest
    participant OG as Origin

    WA->>LD: complete task with branch_name
    LD->>TS: complete_task(task_id, output, branch_name)
    Note over TS: Single Postgres transaction:<br/>1. Mark original task done<br/>2. Create merge task (pending, p0)
    TS-->>LD: (completed_task, merge_task)
    LD->>EB: sync merge task to cache + ready queue
    LD->>EB: publish task.created event
    LD-->>WA: response with merge_task attached

    Note over OR: Next orchestrator tick...

    OR->>EB: scan ready queue for merge tasks
    OR->>EB: SETNX idempotency guard (TTL 600s)
    OR->>TS: claim_task(merge_id, "merge-agent")
    OR->>EB: publish task.claimed event

    OR->>MA: asyncio.create_task(execute_merge_task)

    MA->>EB: publish merge.started event
    MA->>EB: acquire_merge_lock (SET NX EX 300s)

    MA->>Git: checkout main && pull --ff-only
    MA->>Git: merge --no-edit {branch_name}

    alt Merge succeeds
        MA->>PT: run test suite
        alt Tests pass
            MA->>OG: git push origin HEAD
            MA->>Git: worktree remove + branch -D
            MA->>TS: complete_task(merge_id)
            MA->>EB: publish merge.succeeded event
        else Tests fail
            MA->>Git: reset --hard HEAD~1
            MA->>TS: fail_task(merge_id, reason)
            MA->>EB: publish merge.failed event
        end
    else Merge conflict (simple)
        MA->>Git: checkout --theirs for each file
        MA->>Git: commit (auto-resolved)
        Note over MA: Continues to test phase above
    else Merge conflict (complex)
        MA->>Git: merge --abort
        MA->>TS: fail_task(merge_id, conflict details)
        MA->>EB: publish merge.failed event
    else Lock unavailable
        MA-->>OR: MergeResult(status=LOCK_FAILED)
        MA->>TS: fail_task(merge_id)
        MA->>EB: publish merge.failed event
    end

    MA->>EB: release_merge_lock (Lua atomic check-and-delete)
```

## Four Outcomes

### 1. Success

The merge completed, tests passed, and the branch was pushed to origin.

**What happens:**
1. `git checkout main && git pull --ff-only` -- sync with latest main
2. `git merge --no-edit {branch_name}` -- merge the feature branch
3. Test suite runs (default: `uv run pytest tests/ -v -k 'not integration'`)
4. `git push origin HEAD` -- push merged main (best-effort; local-only repos tolerate push failure)
5. Worktree removed (`git worktree remove --force`)
6. Feature branch deleted (`git branch -D`)
7. Merge task marked `done` in Postgres
8. `merge.succeeded` event published

**MergeResult fields:**
- `status`: `"success"`
- `commit_sha`: the merge commit SHA
- `test_output`: truncated pytest output (up to 8000 chars)

### 2. Merge Conflict with Auto-Resolution

A conflict was detected but it was "simple" -- no overlapping content edits (e.g., delete/modify conflicts or non-overlapping changes).

**What happens:**
1. Merge attempt fails -- conflicting files identified via `git diff --name-only --diff-filter=U`
2. Each conflicting file is checked for `<<<<<<< HEAD` / `=======` / `>>>>>>>` markers
3. If no content conflict markers are found, the conflict is auto-resolvable
4. For each file, `git checkout --theirs -- {file}` accepts the branch version
5. `git add` stages the resolution, then `git commit` completes the merge
6. Flow continues to the test phase (same as success path)

**When auto-resolution applies:**
- File deleted on one side, modified on the other
- Files marked as conflicting by git but without actual content overlap

### 3. Merge Conflict Requiring Escalation

A conflict with real content overlap (merge markers present) that cannot be auto-resolved.

**What happens:**
1. Merge fails, conflicting files identified
2. Conflict markers (`<<<<<<< HEAD`, `=======`, `>>>>>>>`) found in at least one file
3. `git merge --abort` rolls back the working tree
4. Merge task marked `failed` with conflict details:
   - List of conflicting files
   - `auto_resolvable: false`
   - Git stderr/stdout as details
5. `merge.failed` event published
6. If escalation is enabled, the orchestrator's retry/escalation pipeline handles the failure

**Human intervention:** Check the conflicting files listed in the task's failure reason, manually resolve, then either retry the merge task or create a new one.

### 4. Test Failure

The merge itself succeeded, but the post-merge test suite failed.

**What happens:**
1. Merge completes cleanly
2. Test suite runs and returns non-zero exit code
3. `git reset --hard HEAD~1` -- rolls back the merge commit from main
4. Merge task marked `failed` with truncated test output (up to 8000 chars)
5. `merge.failed` event published
6. Worktree cleanup still runs in the `finally` block

**Recovery:** Fix the failing tests in the worktree branch (if it still exists) or create a new branch, then re-submit via `loom_done` with the fixed branch.

## Redis Lock Mechanism

The merge agent uses a distributed lock to ensure only one merge-to-main operation runs at a time, even across multiple orchestrator instances.

### Lock Key

```
loom:lock:merge:main
```

Defined as `MERGE_LOCK_KEY` in `loom/bus/channels.py`.

### How It Works

**Acquisition:** Uses `SET key token NX EX {ttl}` atomically. The token is a random UUID hex string. If the key already exists (another merge holds the lock), acquisition fails and returns `None`.

**Release:** Uses a Lua script for atomic check-and-delete:

```lua
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
```

This ensures only the lock holder can release it. If the lock expired (TTL) and was acquired by another merge, the original holder's release is a no-op.

**TTL:** Default 300 seconds (5 minutes). This is the maximum time a single merge can hold the lock. If a merge takes longer (e.g., slow test suite), the lock expires and another merge could start -- though this is unlikely given the sequential nature of the ready queue.

### Idempotency Guard

In addition to the merge lock, the orchestrator uses a separate SETNX guard to prevent dispatching the same merge task twice across overlapping orchestrator ticks:

```
loom:{project_id}:merge:spawned:{task_id}
```

This key has a TTL of 600 seconds (10 minutes). Defined by `merge_spawned_key()` in `loom/bus/channels.py`.

### Manual Lock Release

If a merge agent crashes and the lock is stuck (TTL not yet expired):

```bash
# Check who holds the lock
redis-cli GET loom:lock:merge:main

# Force release (only if you are sure no merge is running)
redis-cli DEL loom:lock:merge:main

# Clear a stuck idempotency guard
redis-cli DEL loom:{project_id}:merge:spawned:{task_id}
```

## Configuration

### Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `LOOM_CLAIM_TTL` | Claim timeout for the merge task (seconds) | `600` |

### Parameters in `execute_merge()`

| Parameter | Description | Default |
|-----------|-------------|---------|
| `target_branch` | Branch to merge into | `"main"` |
| `lock_ttl` | Merge lock TTL in seconds | `300` |
| `auto_resolve` | Attempt auto-resolution of simple conflicts | `True` |
| `run_tests_cmd` | Shell command to run tests post-merge | `None` (skips tests) |

### Parameters in `execute_merge_task()`

| Parameter | Description | Default |
|-----------|-------------|---------|
| `target_branch` | Branch to merge into | `"main"` |
| `test_cmd` | Shell command for post-merge tests | `"uv run pytest tests/ -v -k 'not integration'"` |
| `worktree_path` | Relative path to the worktree directory | From task context |

### Orchestrator Constants

| Constant | Value | Location |
|----------|-------|----------|
| `_MERGE_SPAWN_TTL` | `600` seconds | `loom/orchestration/loop.py` |
| `MERGE_LOCK_KEY` | `"loom:lock:merge:main"` | `loom/bus/channels.py` |
| `_MAX_TEST_OUTPUT_CHARS` | `8000` | `loom/skills/merge.py` |

## Troubleshooting

### Merge task stuck in `pending`

The orchestrator has not dispatched it yet. Check:

```bash
# Is the orchestrator running?
loom orchestrate --once   # Run a single tick manually

# Is the idempotency guard blocking re-dispatch?
redis-cli GET loom:{project_id}:merge:spawned:{task_id}
# If it returns "1", delete it:
redis-cli DEL loom:{project_id}:merge:spawned:{task_id}
```

### Merge task stuck in `claimed`

The merge agent may have crashed or timed out. Check:

```bash
# Check claim expiry
psql -c "SELECT id, status, assignee, claim_expires_at FROM tasks WHERE id = '{task_id}'"

# If claim_expires_at is in the past, the orchestrator sweeper will release it next tick.
# Or release manually:
loom orchestrate --once
```

### Lock held but no merge is running

```bash
# Check the lock
redis-cli GET loom:lock:merge:main
# Returns the token, or (nil) if unlocked

# Check TTL remaining
redis-cli TTL loom:lock:merge:main

# Force release
redis-cli DEL loom:lock:merge:main
```

### Merge succeeded but push failed

This is non-fatal. The merge commit is on local `main` but not on origin. Check:

```bash
# Verify the merge commit exists
git log --oneline -5

# Push manually
git push origin main
```

Push failures are logged at DEBUG level and do not cause the merge task to fail. This accommodates local-only repos without a remote.

### Test failure after merge

The merge was rolled back with `git reset --hard HEAD~1`. The feature branch and worktree may still exist for debugging:

```bash
# Check if worktree still exists
git worktree list

# Check task failure reason for test output
psql -c "SELECT id, output FROM escalations WHERE task_id = '{task_id}' ORDER BY created_at DESC LIMIT 1"
```

### All merges failing with LOCK_FAILED

Another merge is holding the lock. Either wait for it to finish (or TTL to expire), or:

```bash
# Check what is holding the lock
redis-cli GET loom:lock:merge:main

# Check TTL
redis-cli TTL loom:lock:merge:main

# If the holder crashed, force release
redis-cli DEL loom:lock:merge:main
```

## Task State Machine

Merge tasks follow the standard Loom task lifecycle with specific transitions:

```
                                    loom_done(branch_name)
                                         creates
                                           |
                                           v
                                      +---------+
                                      | pending |
                                      +---------+
                                           |
                                  orchestrator_tick()
                                  claim + dispatch
                                           |
                                           v
                                      +---------+
                                      | claimed |
                                      +---------+
                                      (assignee: "merge-agent")
                                           |
                              +------------+------------+
                              |                         |
                         merge succeeds            merge fails
                         tests pass            (conflict/test/lock/error)
                              |                         |
                              v                         v
                          +------+                 +--------+
                          | done |                 | failed |
                          +------+                 +--------+
```

**Key differences from regular tasks:**

- Priority is always `p0` (highest) so merges are dispatched immediately
- Assignee is always `"merge-agent"` (set by the orchestrator, not a human agent)
- The task is claimed and executed by the orchestrator autonomously, not by `loom_claim`
- Context always contains `branch_name`, `parent_task_id`, and `worktree_path`

## Source Files

| File | Role |
|------|------|
| `loom/skills/merge.py` | Core merge executor and full lifecycle (`execute_merge`, `execute_merge_task`) |
| `loom/bus/locks.py` | Distributed lock acquire/release using Redis SET NX + Lua script |
| `loom/bus/channels.py` | `MERGE_LOCK_KEY`, `merge_spawned_key()` key patterns |
| `loom/bus/events.py` | `MERGE_STARTED`, `MERGE_SUCCEEDED`, `MERGE_FAILED` event types |
| `loom/graph/store.py` | `complete_task()` with `branch_name` param, `_create_merge_task_in_txn()` |
| `loom/mcp/tools.py` | `loom_done()` tool that triggers merge task creation |
| `loom/orchestration/loop.py` | `_handle_merge_task_ready()` dispatch with idempotency guard |
