lvlup-sw/exarchos

merge-orchestrator

The composed executor that lands a subagent worktree branch onto an integration branch with preflight + recorded recovery point.

Voir la source
Document Skill original

Rendu depuis le dépôt source en conservant titres, exemples, code, tableaux, liens et images.

Merge Orchestrator Skill

Local Git, Not Remote VCS

This skill performs local `git merge` of a source (subagent) worktree branch into a target (integration) branch, recording a recovery point SHA so the INV-14 recovery ladder (git merge --abortgit reset --keep, never --hard) can rewind the merge on any failure. It does not call the VCS provider (GitHub / GitLab / Azure DevOps) and does not require a PR id. For remote PR merging, see the companion skill that wraps the merge_pr action.

The mental model and the rationale for why these are two separate concerns are documented in references/local-git-semantics.md.

Single-Writer Entry Point: serialize_merge

For a shared integration branch — landing a subagent worktree branch onto the integration ref during the delegate → merge-pending → delegate loop, where a sibling worktree merge can race — `serialize_merge` is THE integration-merge path. It acquires an optimistic per-integrationRef lease (at most one in-flight merge per integration ref), then composes this action UNCHANGED under that lease. Route integration merges through serialize_merge; do not dispatch raw merge_orchestrate for them.

Invoke merge_orchestrate directly only where no concurrent merge can race the target:

  • Non-integration merge — the target is a private / scratch / solo branch no other agent will touch.
  • Crash-resumed caller — a new pid finishing a merge it already leased, re-presenting the ORIGINAL claim's leaseOperationId.
  • The composed executor call that serialize_merge itself makes (it threads its lease operationId so its own call passes the guard).

DR-2 lease guard. Raw merge_orchestrate against an integration ref that carries a live foreign lease (holder operationId ≠ the presented leaseOperationId, holder liveness ∈ {alive, unknown}) fails closed with a structured MERGE_LEASE_HELD error and no git side effect — the message names serialize_merge as the path. A no-lease or provably-dead-holder target proceeds exactly as before (back-compat; a dead holder is not a blocker). So the routing above is enforced at the handler chokepoint, not merely by convention — reach for serialize_merge.

Overview

This skill activates whenever an operator (or an automated next_actions dispatcher) invokes the merge_orchestrate action against Exarchos MCP. After the source branch has been prepared in a worktree, this skill:

  1. Performs a worktree-availability preflight (target branch must not be checked out in a sibling worktree).
  2. Composes additional preflight guards (ancestry, current-branch protection, main-worktree assertion, working-tree drift).
  3. Records pre-merge HEAD of the target branch as a recovery point (a write-ahead-log marker, persisted before any ref mutation).
  4. Performs a local git merge of the source branch into the target branch.
  5. On any failure, runs the INV-14 recovery ladder (git merge --abortgit reset --keep <recoveryPointSha>, never --hard) and surfaces a categorized failure reason.
  6. Emits dedicated event types so the merge timeline is reconstructable from the event log alone.

Resumable: terminal phases (completed / rolled-back / aborted) short-circuit on re-entry without re-emitting events. Idempotent: re-dispatch with the same taskId collapses via the next_actions idempotency key.

Triggers

Activate this skill when:

  • The operator runs exarchos merge-orchestrate ... (CLI).
  • exarchos:exarchos_orchestrate({ action: "merge_orchestrate", ... }) is invoked directly.
  • An automated next_actions envelope surfaces the merge verb (idempotency key <streamId>:merge_orchestrate:<taskId>) for the merge-pending detour.
Route shared-integration merges through `serialize_merge`. When the trigger is a shared integration branch (the merge-pending detour is the common case), land it via serialize_merge per the single-writer entry point above — serialize_merge composes this action under a lease. A direct raw merge_orchestrate dispatch is for a non-integration or already-lease-held merge only; against a leased integration ref it fails closed (MERGE_LEASE_HELD).

Do not activate this skill:

  • To merge a remote PR — that is merge_pr. The two are disjoint actions; see Disambiguation below.
  • When the orchestrator's recorded mergeOrchestrator.phase is already terminal — the resume short-circuit runs but no fresh dispatch is needed.

Process

Schema: discover the action's argument schema with exarchos:exarchos_orchestrate({ action: "describe", actions: ["merge_orchestrate"] }). Strategy is required (no schema-level default) — pick squash / merge / rebase deliberately.

Step 1: Pick the merge strategy

StrategyLocal git operationWhen to choose
mergegit merge --no-ff --no-edit <source> — explicit merge commitPreserves the subagent's commit history with a visible merge boundary.
squashgit merge --squash <source> then git commit — single squash commit on targetSubagent commit history is noise; one logical change should land as one commit.
rebaserebases an ephemeral copy of source onto target then ff-merges target — linear historyNo merge commit; integration branch stays linear. The original source ref is preserved (the rebase runs on a temporary branch that is deleted afterward), so an executor recovery only needs to rewind target to the recovery point.

Strategy is required at the schema layer (collision check + user-visible parity). There is no implicit default — operator intent is always explicit in the event log.

Step 2: Invoke

Integration merge? The direct invocation below is the raw executor form — use it for a non-integration or already lease-held merge. For a shared integration branch, invoke serialize_merge instead (it composes this exact call under a single-writer lease); see the single-writer entry point above.

Via MCP (illustrative — the canonical arg names come from describe):

typescript
exarchos:exarchos_orchestrate({
  action: "merge_orchestrate",
  // workflow-correlation identifier — name per the action's schema
  sourceBranch: "<subagent-branch>",
  targetBranch: "<integration-branch>",
  taskId: "<task-id>",          // present when auto-dispatched from next_actions
  strategy: "squash",            // required
  dryRun: false,                 // optional — preflight only, no executor invocation
  resume: false,                 // optional — short-circuit on terminal phases
})

Via CLI:

bash
exarchos merge-orchestrate \
  --source-branch <subagent-branch> \
  --target-branch <integration-branch> \
  --task-id <task-id> \
  --strategy squash
  # plus the workflow-correlation id flag — see `--help`
  # add --dry-run for preflight-only, --resume for terminal-phase short-circuit

CLI exit codes: 0 = success, 1 = invalid input, 2 = merge failed (preflight blocked or recovery executed), 3 = uncaught exception.

Step 3: Interpret the result

The handler returns a ToolResult whose data.phase discriminates the outcome:

phaseMeaningOperator action
completedLocal merge landed; mergeSha is the new HEAD of target.None — workflow continues per the orchestrator's playbook.
abortedPreflight failed; no merge attempted. data.preflight carries the structured guard sub-results (when produced by the body preflight) OR data.reason discriminates the early-abort cause.See the abort-reason table below. Resolve the underlying condition and re-dispatch.
rolled-backMerge was attempted, failed (reason: 'merge-failed' / 'verification-failed' / 'timeout'), and the INV-14 recovery ladder (git merge --abortgit reset --keep <recoveryPointSha>) ran. The target branch is rewound to the recovery point. (The phase value stays rolled-back — a load-bearing state token unchanged by the recovery reframe.)Inspect data.reason. If data.recoveryError / data.recoveryErrorDetail is also present, the reset itself failed — the working tree is stranded and requires operator intervention.

Abort-reason payload shapes

When phase === 'aborted', the data payload discriminates the cause:

data.reasonPayload fieldsCauseOperator remediation
target-checked-out-elsewheresiblingWorktreePath: string (absolute path)Target branch is already checked out in a sibling worktree of the same repository. Detected by the worktree-availability preflight before any event emission, executor invocation, or state persistence.Resolve the sibling worktree: either remove it (git worktree remove <path>), switch its checkout to another branch, or re-run the merge against a different target. Then re-dispatch (through serialize_merge for a shared integration ref).
(body preflight failures)data.preflight: { ancestry, worktree, currentBranchProtection, drift }Body-preflight guard failed (ancestry mismatch, worktree drift, protected current branch, etc.).Inspect preflight.* sub-results to identify which guard failed. Resolve the underlying condition (e.g., commit/stash drift, switch off a protected branch) and re-dispatch.

The target-checked-out-elsewhere abort path is special: it suppresses both the merge.requested and merge.preflight events and skips state persistence entirely. This guarantees the event log is never contaminated with an attempt that could not have captured a correct recovery point (the executor would have read HEAD from the wrong worktree).

For the full recovery flow per outcome, see references/recovery-runbook.md.

Step 4: Confirm event emissions

Events are emitted directly to the orchestrator's event stream (stream id is the value passed as streamId) — not wrapped in gate.executed:

Event typeWhenCarries
merge.preflightAlways (after preflight runs, before any merge attempt) — except for the early-abort target-checked-out-elsewhere path, which emits nothingFull structured guard sub-results + failureReasons if passed: false
merge.requestedAfter preflight passes, before the executor runs (Phase A intent record from the two-event split) — suppressed on the early-abort target-checked-out-elsewhere pathsourceBranch, targetBranch, strategy, taskId
merge.executedOn successful local mergemergeSha, rollbackSha, taskId, source/target branches
merge.recoveredOn post-merge failure followed by recovery — the canonical recovery event and, since DR-2, the only one emittedrecoveryPointSha, reason, recoveryError?, recoveryErrorDetail?, taskId, source/target branches
merge.rollbackRETIRED (DR-2) — legacy wire shape, read-tolerant-not-emittable: nothing writes it any more; it survives only in pre-DR-2 event logs, where it folds identically to merge.recoveredrollbackSha, reason, taskId, source/target branches
Recovery vocabulary. The canonical frame is database-transaction, not saga: a recovery point (the recorded HEAD SHA, persisted before the merge as a write-ahead-log marker) and a recovery event (merge.recovered). merge.recovered is the successor to merge.rollback and, since DR-2, the sole recovery event the executor emits; the legacy merge.rollback write path is retired (read-tolerant-not-emittable, kept only so pre-DR-2 logs replay identically). merge.recovered carries the resolved recoveryPointSha / recoveryErrorDetail names alongside the unchanged INV-14 recoveryError discriminator; the retained merge.executed event still carries its rollbackSha wire field. The phase value 'rolled-back' is intentionally retained.

These events are auto-emitted by the handler — do not manually append them via exarchos:exarchos_event during normal operation. Manual emission is only sanctioned during the documented manual-recovery flow in `recovery-runbook.md` when a merge has been completed out-of-band (e.g., conflict resolution) and the event log must be brought back in sync — follow that runbook's event-first sequencing.

Discover the event payload schemas via exarchos:exarchos_event({ action: "describe", eventTypes: ["merge.preflight", "merge.requested", "merge.executed", "merge.rollback", "merge.recovered"] }). (merge.rollback is legacy, read-tolerant — retained so pre-DR-2 logs replay, no longer emitted.)

Disambiguation: merge_orchestrate vs merge_pr

Two related actions, two distinct concerns:

Aspectmerge_orchestrate (this skill)merge_pr (companion skill)
LayerLocal SDLC handoffRemote PR primitive
What it mergesA subagent worktree branch into a target branchA user-facing PR via the VCS provider API
Identifier requiredsourceBranch + targetBranchprId
Underlying operationgit merge (local)provider.mergePr() (remote API)
RecoveryINV-14 recovery ladder: git merge --abortgit reset --keep <recoveryPointSha> (real, rewinds the merge; never --hard)None — the VCS provider owns merge state
Eventsmerge.preflight / merge.requested / merge.executed / merge.recovered (legacy merge.rollback retired — read-tolerant, not emitted)pr.merged

If you reach for merge_orchestrate thinking "I want to merge a PR," you want merge_pr instead.

Resume Semantics

When invoked with resume: true, the handler reads existing mergeOrchestrator state. Terminal phases (completed / rolled-back / aborted, members of EXCLUDED_MERGE_PHASES) short-circuit and return the recorded result with no new events and no executor call. Non-terminal phases (pending / executing) fall through to a fresh preflight + executor run, which is safe because the underlying git operations are idempotent on already-merged branches.

When invoked without resume, prior state is deliberately ignored — fresh-dispatch semantics.

Note: the target-checked-out-elsewhere early-abort path runs before the resume state read, so it short-circuits regardless of resume and never persists state.

Dry-Run

dryRun: true runs the body preflight, emits merge.preflight, and short-circuits before the executor runs and before any state persistence. Returns { dryRun: true, preflight, phase: 'pending' | 'aborted' }. Useful for CI integrations that check merge readiness before the merge window opens. Dry-run still observes the worktree-availability early abort — a sibling-worktree conflict aborts with reason: 'target-checked-out-elsewhere' even under dryRun.

Anti-Patterns

Don'tDo Instead
Dispatch raw merge_orchestrate to land onto a shared integration branchRoute through serialize_merge (single-writer lease); raw merge_orchestrate is for a non-integration / lease-held merge — a live foreign lease makes it fail closed (MERGE_LEASE_HELD)
Use this skill to merge a remote PRUse the merge_pr skill
Manually emit merge.preflight / merge.requested / merge.executed / merge.recovered in normal flowLet the handler auto-emit; manual emission causes duplicates (one exception: documented manual-recovery flow in `recovery-runbook.md`)
Wrap merge events under gate.executedDirect stream append with the dedicated event type — these are state transitions, not gate executions
Re-dispatch after a rolled-back outcome without inspecting the reasonRead data.reason and data.recoveryErrorDetail (with the data.recoveryError discriminator); address the root cause first
Re-dispatch after reason: 'target-checked-out-elsewhere' without first freeing the sibling worktreeRemove or re-checkout the sibling worktree referenced by data.siblingWorktreePath, then re-dispatch
Omit --strategy / strategy: field expecting a defaultStrategy is required; supply squash / merge / rebase explicitly
Invoke from a subagent worktreePreflight refuses (main-worktree assertion); invoke from the main worktree

Diagnostics

Set EXARCHOS_PREFLIGHT_DEBUG=1 in the environment before invoking merge_orchestrate to attach a structured debug payload to merge.preflight events. The payload is gated on two conditions, both of which must hold:

  1. EXARCHOS_PREFLIGHT_DEBUG=1 is present in the orchestrator process environment.
  2. The preflight's ancestry guard failed (i.e., ancestry.passed === false).

Passing preflights do not carry the debug payload even when the env var is set — the failure-only gating is deliberate (event-store growth concern). A future EXARCHOS_PREFLIGHT_DEBUG=2 channel may add verbose / passing-preflight diagnostics; that is out of scope for phase 1.

The debug block carries nine fields:

FieldSourcePurpose
gitVersiongit --versionDifferentiate behaviors across git releases.
repoRootgit rev-parse --show-toplevelDistinguish symlinked or normalized vs raw repo paths.
worktreeListgit worktree list --porcelainSurface sibling worktree topology that could affect ref resolution.
refsHeadsSourcegit for-each-ref refs/heads/<source>SHA + packed-state of the source branch ref.
refsHeadsTargetgit for-each-ref refs/heads/<target>SHA + packed-state of the target branch ref.
mergeBaseCommandconstructedExact argv re-run by the helper, including 'git' prefix — copy-pasteable for the operator.
mergeBaseExitCodererun of git merge-base --is-ancestor <target> <source>The exit code that drove the ancestry failure.
mergeBaseStdoutsame invocationCaptured stdout.
mergeBaseStderrsame invocationCaptured stderr (collapsed into stdout under the default git adapter).

Fail-closed: any individual git invocation that fails inside the debug helper degrades to an empty string / default value for that field rather than throwing. The debug attachment must never mask the underlying preflight failure the operator is trying to investigate.

Reporting workflow. When you capture a debug-bearing event, attach the full data.debug block to a new GitHub issue tagged with relevant scope labels (e.g., windows, merge-orchestrator, preflight). Phase-2 root-cause analysis depends on at least one real-host event with this payload.

Schema Discovery

For the argument schema, call exarchos:exarchos_orchestrate({ action: "describe", actions: ["merge_orchestrate"] }). Event payload shapes come from exarchos:exarchos_event({ action: "describe", eventTypes: ["merge.preflight", "merge.requested", "merge.executed", "merge.rollback", "merge.recovered"] }) (merge.rollback is legacy, read-tolerant — retained so pre-DR-2 logs replay, no longer emitted).

mergeOrchestrator.* fields on workflow state are written by this skill and mergeOrchestrator.phase is read by gates; the underlying phase workflow field is immutable and must be changed via transition, not update. See the Reserved fields section in the checkpoint skill for the full immutable-key list and the typed RESERVED_FIELD error envelope.

Completion Criteria

  • [ ] Preflight result passed: true (or operator has decided to proceed despite a documented preflight gap)
  • [ ] mergeOrchestrator.phase === 'completed' in orchestrator state
  • [ ] merge.executed event present in the stream with the recorded mergeSha and rollbackSha (the legacy wire field for the recovery point, retained during the v2.11.x deprecation window)
  • [ ] Target branch's HEAD matches the recorded mergeSha

If any criterion fails, consult references/recovery-runbook.md before re-dispatching.

du même dépôt

Autres Skills

Tous les Skills