# Design: `.merge-*` Tasks — Visible, Retryable, Batchable Merge Surface

**Task:** research-merge-task-design
**Date:** 2026-04-25
**Status:** Design proposal

---

## 1. Survey of Current Merge-Back Code Paths

### Primary merge-back: shell wrapper in `run.sh`

The merge-back logic lives entirely in a **shell script fragment** generated by `src/commands/spawn/execution.rs:1278–1335`. It runs inside the agent's `run.sh` wrapper after the agent process exits, not in the coordinator or any Rust code path.

**Flow:**

```
Agent exits → wrapper checks task status → if done/pending-validation:
  1. Count commits on worktree branch (git log HEAD..$WG_BRANCH)
  2. Acquire file lock (.wg-worktrees/.merge-lock via flock)
  3. git merge --squash $WG_BRANCH
  4. If conflict → git merge --abort → wg fail (marks task failed)
  5. If clean → git commit -m "feat: $TASK_ID ($WG_AGENT_ID)"
  6. Release flock
  7. Touch .wg-cleanup-pending marker
```

**Key locations:**
- **Merge execution:** `src/commands/spawn/execution.rs:1278–1335` — shell heredoc in `write_wrapper_script()`
- **Lock file:** `.wg-worktrees/.merge-lock` — serializes concurrent merges via `flock`
- **Cleanup marker:** `.wg-cleanup-pending` — signals coordinator to sweep worktree
- **Coordinator sweep:** `src/commands/service/worktree.rs:709–853` — `sweep_cleanup_pending_worktrees()`
- **Orphan cleanup:** `src/commands/service/worktree.rs:594–707` — `cleanup_orphaned_worktrees()`
- **Commit recovery:** `src/commands/service/worktree.rs:442–468` — `recover_commits()`

### Serialization mechanism

The `flock` on `.merge-lock` serializes concurrent merges. This is critical because `git merge --squash` operates on the shared main worktree's index. Without this lock, two agents finishing simultaneously would race on the index, producing corrupted state.

### No provenance/audit trail for merges

The merge-back produces:
- A commit message (`feat: $TASK_ID ($WG_AGENT_ID)`) — only on success
- A line in the agent's output file (`[wrapper] Merged ...` or `[wrapper] Merge conflict ...`) — ephemeral
- A `wg fail` call on conflict — but the failure reason is a plain string with no structured metadata

There is no entry in the provenance log (`operations.jsonl`), no graph-visible task tracking the merge, and no structured record of conflict details.

---

## 2. Catalog of Failure Modes from Recent History

### Failure Mode 1: Both-modified conflict silently corrupts main (2026-04-25)

**Commit:** `6323d51a2` — "merge: resolve stronger-automatic-retry conflicts"

Agent-288 (`add-incomplete-retryable`) and agent-299 (`stronger-automatic-retry`) both modified `src/commands/incomplete.rs`. Agent-288's merge-back completed first (squash-merge succeeded). Agent-299's merge-back hit a conflict with the now-modified file. The `git merge --abort` + `wg fail` path should have prevented corruption, but the task had already been marked `done` by the agent before the merge-back section ran. The unresolved merge state blocked `cargo install` for hours until manual human intervention.

**Root cause:** The merge-back runs *after* the task is marked done. The sequence is: agent calls `wg done` → wrapper runs merge-back → conflict → `wg fail` (overriding the done status). But `wg fail` on an already-done task depends on the task still being mutable, and the failure reason is a plain string with no structured conflict metadata.

**Impact:** Silent corruption of main branch. No automated retry. No signal to other agents or the coordinator.

### Failure Mode 2: Unresolved conflict markers committed to main (2026-04-24)

**Commit:** `f2576ae28` — "test(worktree): resolve conflict — take agent-118 cleanup tests"

Agent-118 (`fix-expose-missing-v2`) merge-back left **unresolved conflict markers** in `src/commands/service/worktree.rs`. The file contained duplicate function calls, `<<<<<<< HEAD` markers, and references to variables that didn't exist on the target branch. The file didn't compile under `cargo test`.

**Root cause:** This suggests the `git merge --squash` succeeded (exit code 0) despite having conflicts — possible if git auto-resolved some hunks but left textual conflicts that the merge driver considered "resolved" with conflict markers. Alternatively, a race condition where `git merge --abort` was interrupted.

**Impact:** Broken compilation on main. Required manual human resolution.

### Failure Mode 3: Multi-branch merge conflict (2026-03-07)

**Commit:** `9d7f01077` — "fix: resolve merge conflicts from fix-auto-task-edges and tui-pink-lifecycle"

Two feature branches (`fix-auto-task-edges` and `tui-pink-lifecycle`) both modified overlapping files in `src/tui/viz_viewer/`. The merge-back interleaved changes from both branches, requiring manual resolution across 4 files.

**Root cause:** Temporal coincidence — both agents finished at similar times, and the serialized merge of the second branch conflicted with the first.

**Impact:** 4 files with semantic conflicts requiring understanding of both features to resolve correctly.

### Failure Mode 4: Worktree escape (observed in code, not logs)

`execution.rs:1283–1288` contains a check for a missing `.git` pointer inside the worktree, with the comment: "If the agent escaped to a Claude Code worktree, the wg worktree's .git may have been severed." This suggests a known failure mode where the agent's tool runner creates its own worktree that conflicts with the wg worktree, leaving the merge-back with no valid git state.

### Failure Mode 5: No-commit branches

When an agent fails without making any commits, the merge-back correctly skips the merge (`execution.rs:1321`). However, the agent may have modified files in the worktree without committing them. These uncommitted changes are lost when the worktree is cleaned up.

### Failure Mode 6: main moves during agent work

When agent A finishes and merges, the main branch HEAD advances. When agent B finishes later, its branch was forked from the *old* HEAD. The `git merge --squash` operates against the *new* HEAD, increasing the conflict surface. There is no rebase step.

---

## 3. Comparison Table: Trigger Strategies

| Strategy | Description | Pros | Cons | Best for |
|----------|-------------|------|------|----------|
| **Per-task** | `.merge-X` created when task completes | Simple mental model; immediate feedback; easy to attribute failures; matches current behavior | More commits on main; each merge is against latest HEAD (no batching benefit) | Low-parallelism workloads (1–3 agents); critical tasks needing fast feedback |
| **Batched (count)** | `.merge-batch-K` created when N agents complete | Fewer commits; single integration point; can detect cross-task conflicts early | Longer feedback loop; harder to attribute which task caused a conflict; blocking — all N tasks wait for batch | High-parallelism bursts where N agents finish near-simultaneously |
| **Time-based** | Merge whatever's accumulated every M minutes | Predictable cadence; low coordinator complexity | Arbitrary delays for single completions; waste when nothing accumulated; N=1 case degenerates to per-task with extra latency | Continuous-integration-style pipelines with steady throughput |
| **Threshold** | Merge when N agents done OR M minutes elapsed, whichever first | Combines best of count + time; adapts to workload | Most complex trigger logic; two tuning parameters | Mixed workloads with bursts and quiet periods |
| **Pull-style** | Separate merge coordinator polls for completed tasks | Complete isolation of merge logic; can run on separate machine; easy to scale | Extra daemon/process; added latency from polling interval; coordination overhead | Large-scale deployments with dedicated merge infrastructure |

### Recommendation: **Per-task with opportunistic batching**

Per-task is the right default. It matches the current mental model (agent finishes → merge happens), provides immediate feedback, and makes conflict attribution trivial. The `flock` serialization already handles the concurrent case correctly.

Add **optional batching** as a config knob (`merge_strategy: per-task | batched`) for users who want fewer commits. The batched mode would collect completed-but-unmerged tasks and create a single `.merge-batch-K` task periodically (using the threshold strategy with configurable N and M).

**Why not batched by default:**
1. Attribution is critical. When a merge fails, you need to know which task's changes caused it. Per-task makes this trivial.
2. Latency matters. The sooner a merge conflict surfaces, the sooner the retry/resolution can begin.
3. The `flock` already serializes, so there's no contention benefit from batching.

---

## 4. Proposed Design: `.merge-*` Task Data Model

### Task schema

```
.merge-<task-id>
  title: "Merge: <task-id>"
  status: open → in-progress → done/incomplete/failed
  after: [<task-id>]           # depends on the source task
  before: [.flip-<task-id>]    # FLIP/eval should run on merged code
  tags: ["merge", "agency"]
  exec: "wg merge <task-id>"   # new CLI command
  exec_mode: "bare"            # inline execution (no agent spawn, no worktree)
  visibility: "internal"
  
  # Merge-specific metadata (stored in task fields or description)
  source_branch: "wg/<agent-id>/<task-id>"
  target_branch: "main"  # or whatever HEAD is
  merge_strategy: "squash"
  attempt_count: 0
  last_conflict_files: []
```

### Status transitions

```
                           ┌──────────────────────────────┐
                           │                              │
                           ▼                              │
  Open ──→ InProgress ──→ Done                            │
                  │                                       │
                  ├──→ Incomplete ──→ Open (auto-retry)───┘
                  │       (retryable conflict;
                  │        max_retries applies)
                  │
                  └──→ Failed (terminal)
                        (non-retryable: binary conflict,
                         exhausted retries, worktree gone)
```

**Done:** Clean merge. Commit created on main. Branch metadata preserved in provenance.

**Incomplete:** Auto-resolvable or simple textual conflict. The task is retried with `ready_after` cooldown. The retry mechanism (from `add-incomplete-retryable` / `stronger-automatic-retry`) handles this natively — `.merge-*` tasks can use the same retry machinery.

**Failed:** Terminal failure after exhausted retries, or a conflict type that can't be auto-resolved (binary files, structural semantic conflicts). At this point, a conflict-resolution agent or human intervention is needed.

### New CLI command: `wg merge <task-id>`

This replaces the shell script fragment. Implementation in Rust gives us:
- Structured error handling and provenance logging
- Access to graph state for intelligent retry decisions
- Ability to run pre-merge checks (compilation, test subsets)

```
wg merge <task-id>
  --strategy squash|merge|rebase  (default: squash)
  --auto-resolve ours|theirs|none (default: none)
  --dry-run                       (check for conflicts without merging)
```

### Provenance entry for merge operations

```json
{
  "timestamp": "2026-04-25T14:30:00Z",
  "op": "merge",
  "task_id": ".merge-my-task",
  "actor": "coordinator",
  "detail": {
    "source_branch": "wg/agent-377/my-task",
    "target_branch": "main",
    "strategy": "squash",
    "result": "done",
    "commit_sha": "abc1234",
    "files_changed": 5,
    "insertions": 120,
    "deletions": 30,
    "conflict_files": [],
    "attempt": 1,
    "duration_ms": 340
  }
}
```

---

## 5. Pipeline Ordering Diagram

### Current pipeline (implicit merge)

```
.assign-X → X (worktree) → [implicit merge-back in shell] → .flip-X → .evaluate-X
                                     │
                          (no graph visibility)
```

### Proposed pipeline (explicit .merge-X)

```
.assign-X → X (worktree) → .merge-X → .flip-X → .evaluate-X
                               │
                    (visible in graph, retryable,
                     provenance-logged)
```

**Key changes:**
1. `.merge-X` is created by the eval scaffold alongside `.flip-X` and `.evaluate-X`
2. `.flip-X` depends on `.merge-X` (not directly on `X`) — FLIP evaluates merged code
3. `.evaluate-X` depends on `.flip-X` (unchanged)
4. The shell wrapper merge-back section is removed; `.merge-X` is dispatched as a bare/inline task

**When `.merge-X` is incomplete/failed:**
- `.flip-X` and `.evaluate-X` remain blocked (they depend on `.merge-X`)
- The retry machinery handles re-dispatching `.merge-X`
- If retries exhaust, `.merge-X` goes to `Failed` and a human or conflict-resolution agent intervenes
- The source task `X` remains `done` — its *work* is complete, only *integration* failed

**When no worktree is used (exec_mode=light/bare):**
- `.merge-X` is still created but auto-completes (no worktree branch to merge)
- This keeps the pipeline uniform — FLIP/eval always go through the same chain

---

## 6. Conflict-Resolution Agent Design

### Recommendation: Yes, support conflict-resolution agents (Phase 2)

When `.merge-X` goes to `Incomplete` and exceeds a configurable retry threshold (or encounters a conflict type that simple retry won't fix), the coordinator can create a `.resolve-merge-X` task:

```
.merge-X (incomplete, retries exhausted)
  → .resolve-merge-X (spawned with both-sides context)
      → .merge-X (reset to Open, retry with resolved content)
```

### Conflict-resolution agent context

The `.resolve-merge-X` task description would include:
- **Source branch diff:** `git diff main...$WG_BRANCH`
- **Target state:** current HEAD of main
- **Conflict markers:** exact files and hunks with conflicts
- **Both sides' intent:** task descriptions for `X` and for whatever task merged first
- **Merge strategy hints:** which files are likely "ours" vs "theirs" vs "both"

### Agent capabilities needed

The resolution agent needs:
1. Read access to both branches
2. Ability to produce a resolved version of each conflicted file
3. Ability to run `cargo build` / `cargo test` on the resolved state
4. Write the resolved files and commit

### Implementation approach

The resolution agent works in a temporary worktree (like any other task agent):
1. Check out main at HEAD
2. Apply the source branch changes (squash)
3. Where conflicts exist, read both sides and produce a resolution
4. Commit the resolved merge
5. Mark `.resolve-merge-X` as done

This is a **Phase 2** feature. For Phase 1, `.merge-X` going to `Failed` surfaces the issue visibly and lets humans resolve it (which is already better than silent corruption).

---

## 7. Migration Plan: Implicit Merge → `.merge-*` Tasks

### Phase 1: Add `.merge-*` scaffolding alongside existing merge-back

1. **Extend `eval_scaffold.rs`** to create `.merge-X` tasks in the pipeline:
   - Insert `.merge-X` between `X` and `.flip-X`
   - Wire dependencies: `X → .merge-X → .flip-X → .evaluate-X`
   - `.merge-X` uses `exec: "wg merge <task-id>"` and `exec_mode: "bare"`

2. **Implement `wg merge <task-id>` command** in Rust:
   - Port the shell merge-back logic to a proper Rust command
   - Add provenance logging
   - Add structured conflict detection and reporting
   - Support `--dry-run` for pre-merge checks

3. **Keep the shell merge-back as a fallback:**
   - Check if `.merge-X` exists in the graph
   - If yes: skip the shell merge-back (`.merge-X` will handle it)
   - If no: run the existing shell merge-back (backwards compatibility)

4. **Feature flag:** `merge_tasks_enabled: bool` in config (default: false initially, then true)

### Phase 2: Remove shell merge-back, add resolution agents

1. Remove the shell merge-back section from `write_wrapper_script()`
2. All merges go through `.merge-*` tasks
3. Add `.resolve-merge-X` scaffolding for conflict resolution
4. Add batching mode as a config option

### Backwards compatibility

- In-flight tasks (already running with the shell merge-back) continue to work because Phase 1 keeps both paths
- The shell merge-back checks `if [ -n "$WG_WORKTREE_PATH" ]` — tasks without worktrees (like this research task) skip it
- The feature flag allows gradual rollout
- `.merge-X` tasks for non-worktree tasks auto-complete (no branch to merge)

### Migration sequence for the eval scaffold

Current scaffolding creates:
```
.assign-X → X → .flip-X → .evaluate-X
```

New scaffolding creates:
```
.assign-X → X → .merge-X → .flip-X → .evaluate-X
```

The only change is inserting `.merge-X` into the chain and re-pointing `.flip-X`'s dependency from `X` to `.merge-X`.

---

## 8. Implementation Breakdown: Follow-up Tasks

These tasks should be filed as wg tasks with the dependencies shown:

### Task 1: `implement-wg-merge-command`
**Description:** Implement `wg merge <task-id>` CLI command in Rust. Port the shell merge-back logic (acquire lock, squash merge, commit, conflict handling) to a proper command with structured error handling and provenance logging.
**Files:** `src/commands/merge.rs` (new), `src/commands/mod.rs`, `src/cli.rs`, `src/main.rs`
**Validation:** `cargo test test_merge_` passes; `wg merge --help` works; provenance entry written on merge
**Depends on:** this research task

### Task 2: `scaffold-merge-tasks`
**Description:** Extend `src/commands/eval_scaffold.rs` to create `.merge-X` tasks in the pipeline. Insert `.merge-X` between `X` and `.flip-X` with proper dependency wiring. Auto-complete for non-worktree tasks.
**Files:** `src/commands/eval_scaffold.rs`
**Validation:** `.merge-X` task appears in graph after task publish; `.flip-X` depends on `.merge-X`
**Depends on:** `implement-wg-merge-command`

### Task 3: `wire-merge-task-dispatch`
**Description:** Update coordinator tick to dispatch `.merge-X` tasks inline (like `.assign-X` and `.flip-X`). Add merge-specific scheduling: `.merge-X` should run in the coordinator's tick loop when the source task is done/pending-validation and the worktree branch has commits.
**Files:** `src/commands/service/coordinator.rs`
**Validation:** Coordinator spawns `.merge-X` tasks; merge completes and unblocks `.flip-X`
**Depends on:** `scaffold-merge-tasks`

### Task 4: `remove-shell-merge-back`
**Description:** Remove the shell merge-back section from `write_wrapper_script()` in `src/commands/spawn/execution.rs`. Add a guard: if `.merge-X` exists in the graph, skip the shell merge-back. Eventually remove the shell section entirely.
**Files:** `src/commands/spawn/execution.rs`, `src/commands/spawn/mod.rs` (update tests)
**Validation:** Shell merge-back no longer runs when `.merge-X` task exists; existing tests updated
**Depends on:** `wire-merge-task-dispatch`

### Task 5: `merge-retry-integration`
**Description:** Integrate `.merge-X` with the incomplete/retry machinery. When a merge conflict occurs, set `.merge-X` to `Incomplete` with retry metadata. Configure `max_retries` and `ready_after` for merge tasks.
**Files:** `src/commands/merge.rs`, `src/commands/incomplete.rs`
**Validation:** Merge conflict → task goes to Incomplete → retried after cooldown → eventually Failed if exhausted
**Depends on:** `wire-merge-task-dispatch`

### Task 6: `merge-provenance-and-audit`
**Description:** Add structured provenance entries for all merge operations (success, conflict, retry, resolution). Log to `operations.jsonl` with merge-specific detail fields.
**Files:** `src/commands/merge.rs`, `src/provenance.rs`
**Validation:** Every merge attempt produces a provenance entry; `wg trace` shows merge history
**Depends on:** `implement-wg-merge-command`

### Task 7 (Phase 2): `implement-merge-resolution-agent`
**Description:** When `.merge-X` exhausts retries, create `.resolve-merge-X` task with both-sides context. Resolution agent gets conflict files, both branch diffs, and both task descriptions.
**Files:** `src/commands/service/coordinator.rs`, `src/commands/merge.rs`
**Validation:** Failed `.merge-X` triggers `.resolve-merge-X` creation; resolution agent can read conflict context
**Depends on:** `merge-retry-integration`

### Dependency graph

```
research-merge-task-design
  ├── implement-wg-merge-command
  │     ├── scaffold-merge-tasks
  │     │     └── wire-merge-task-dispatch
  │     │           ├── remove-shell-merge-back
  │     │           └── merge-retry-integration
  │     │                 └── implement-merge-resolution-agent (Phase 2)
  │     └── merge-provenance-and-audit
```

---

## Appendix: Design Decisions and Rationale

### Why squash-merge (not regular merge)?

The current system uses `git merge --squash` and the design preserves this. Squash-merge produces one commit per task on main, making the history clean and attributable. Regular merge commits would clutter the log with branch-point references that add no information (the worktree branches are ephemeral).

### Why `.merge-X` uses `exec_mode: "bare"` (not a full agent)?

Merge is a mechanical operation — acquire lock, run git commands, check result. It doesn't need an LLM, a worktree, or a full agent context. Running it as a bare/inline task (like `.assign-X` and `.flip-X`) keeps latency low and avoids wasting agent slots.

### Why `Incomplete` (not `Failed`) for retryable conflicts?

The `Incomplete` status was specifically designed for retryable transient failures (added in `add-incomplete-retryable`). Merge conflicts are textually transient — the conflict exists because of the current state of main, which may change as other merges complete or humans intervene. Using `Incomplete` + retry is a natural fit.

### Why not rebase instead of merge?

Rebase rewrites history on the source branch, which is problematic for worktree branches that may be referenced by the agent registry, recovery branches, or other metadata. Squash-merge onto main is a forward-only operation that doesn't modify the source branch.

### Why FLIP should evaluate merged code (not worktree code)?

FLIP measures whether the agent's output matches the task intent. If the merge introduces subtle changes (conflict resolution, squash combination), the actual state on main may differ from the worktree branch. Evaluating post-merge code gives a more accurate fidelity signal.
