# Live-checkout dispatch mode

> Per-project opt-in (`project.worktreeMode: 'live'`) that runs Minions agents directly inside the operator's project checkout instead of an engine-managed git worktree.
>
> Plan: `plan-w-mq5rmtt9000a42f9-2026-06-08`. PRD: `prd/minions-opg-2026-06-10.json` (items `P-a3f9b201` … `P-a3f9b209`). Default for every project remains `'isolated'`; the rest of this doc only applies when a project flips itself to `'live'`.

## Motivation

The default `isolated` mode spawns each dispatch inside its own git worktree under `../worktrees/`. That works for almost every repo, but it falls over when:

- **`repo`-managed multi-project trees** (Android AOSP / Office mobile / Chromium) where the manifest pins dozens of nested repos and `git worktree add` either errors out or strands sub-projects.
- **Submodule-heavy repos** where worktrees skip `.gitmodules` configuration or leave submodules pointing at the wrong SHA.
- **Native build state** that lives next to the source (Gradle caches, Xcode `DerivedData`, CMake `build/`, sccache, `.cargo/`, Bazel `bazel-*` symlinks). A fresh worktree throws all of it away and the next build re-downloads / re-compiles.
- **Deep Windows paths** that hit MAX_PATH (260 chars) once `../worktrees/dispatch-<long-id>-<slug>/<repo-name>/<deep/nested/path>` is appended.
- **Sparse clones with custom hooks** (`.git/hooks/*`, `git lfs install --local`, `git config --local`) that the operator has set up by hand and that a fresh worktree does not inherit.

For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.

## Contract (four guarantees)

Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has four guarantees; the engine enforces all four and fails dispatches that would violate them.

### 1. Per-project mutating-concurrency cap of 1

Only one mutating dispatch (fix / implement / review / test / verify / decompose / docs) runs per live-mode project at a time. Read-only types (meeting / ask / explore / plan / plan-to-prd) are excluded from the cap because they never write to disk.

Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dispatch list at every allocation pass and per-tick re-annotation; pending items whose project is already in the set are skipped with a `skipReason` and re-tried on the next tick. (`engine.js:7198`, `engine.js:7460`.)

### 2. Refuse-on-dirty (engine never resets the operator tree)

Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) refuses the dispatch immediately:

- Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically).
- Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
- Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
- Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.

The engine never calls `git reset --hard`, `git clean -fd`, `git stash`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. Cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`).

### 3. No auto-pull, no `--force`, no fast-forward

After the clean-tree check, `prepareLiveCheckout`:

1. Best-effort `git fetch origin <mainRef>` — failure is logged at `warn` and execution continues so offline operators can resume work on an already-checked-out branch.
2. `git rev-parse --verify <branchName>`:
   - Succeeds → branch exists locally → plain `git checkout <branchName>` (no `--force`, no `-B`, no pull, no reset). The operator owns the checkout state; if they have local commits or a stale tip relative to `origin/<branchName>`, those are preserved.
   - Fails → `git checkout -b <branchName> origin/<mainRef>` (new branch, tracks `origin/<mainRef>` as its starting point).

PR-source live dispatches (`meta.branch` set) and shared-branch live dispatches (`meta.branchStrategy === 'shared-branch'`) flow through the same code path with no special case. If the operator already has a PR branch checked out at a different SHA than `origin/<branch>`, the engine will not fast-forward it — that is the intended contract (see Open Q5 in the source PRD). Resolve manually with `git pull --ff-only` or commit the local divergence first.

### 4. No worktree pool, no per-WI subdirectory isolation

Live mode shares one checkout per project. There is no pool to recycle, no quarantine directory, no per-WI subdirectory under the project root. The mutating-concurrency cap (Guarantee 1) is the only isolation mechanism: agents take turns in the same directory.

Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate on `worktreePath` truthiness and `!liveMode`, so the borrow / return / orphan-GC paths execute zero git commands when the project is live.

## Operator workflow

### Enabling live mode (dashboard)

1. Open the Minions dashboard → **Settings** → **Projects** → expand the target project.
2. Set **Worktree mode** → **Live checkout**. A yellow warning chip appears immediately:
   > ⚠ Live mode: dispatches run directly in this repo's checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.
3. Click **Save**. The dashboard POSTs the change through `mergeSettingsConfigUpdate`; `shared.validateWorktreeMode` rejects anything other than `'isolated'` or `'live'` with HTTP 400.

### Enabling live mode (config.json)

```jsonc
{
  "projects": [{
    "name": "android-aosp",
    "localPath": "/home/yemi/aosp",
    "worktreeMode": "live",
    // …
  }]
}
```

Absent / `null` / `''` reads as `'isolated'` (the default) — explicit is preferred.

### Recovering from `live_checkout_dirty` refusal

When dispatch is blocked by a dirty tree, the dashboard shows the work item as pending with `_pendingReason: 'live_checkout_dirty'` and an inbox alert lists the dirty files. From the project checkout:

```bash
# Option A — preserve work for later
git stash push --include-untracked --message "minions: pre-dispatch stash"

# Option B — commit the changes (preferred if they are intentional)
git add -A && git commit -m "wip: hand-off to minions"

# Option C — discard the changes
git checkout -- .
git clean -fd
```

Then re-dispatch. The next tick will re-pick the pending work item; nothing else needs clearing on the engine side (the inbox alert is informational only).

### Branch-leak mitigation

Live mode never deletes branches it creates. Over time the project checkout accumulates `work/W-…` and `user/<login>/<wi>-<slug>` branches. Periodically prune merged branches:

```bash
# Delete all local branches already merged into the current branch (typically main).
git checkout main && git pull --ff-only
git branch --merged | grep -E '^\s+(work/|user/)' | xargs -r git branch -d

# More aggressive (force-delete branches whose tip is gone from the remote):
git fetch --prune origin
git for-each-ref --format '%(refname:short) %(upstream:track)' refs/heads \
  | awk '$2 == "[gone]" {print $1}' \
  | xargs -r git branch -D
```

The engine has no opinion about local branches; this hygiene is the operator's responsibility in live mode.

## Non-goals

Live-checkout mode is deliberately small. These are NOT supported and will not be added:

- **No `auto` mode.** The choice between `isolated` and `live` is per-project and operator-set. The engine will not auto-detect submodules / `repo` workspaces and silently switch modes.
- **No auto-stash on dirty refusal.** The engine refuses and exits; it never `git stash`es to "make room" for a dispatch. Stashes silently mutate the operator's tree and conflate engine state with operator state.
- **No concurrent dispatches per project.** The cap is 1; raising it would require per-WI subdirectories, which live mode explicitly does not provide.
- **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `worktreeMode: 'isolated'` (the default).
- **No per-WI override.** `worktreeMode` is per-project only. There is no `meta.worktreeMode` on a work item that overrides the project setting.
- **No auto-pull / no fast-forward on existing branches.** See Guarantee 3. If a PR branch is checked out locally at a different SHA than `origin/<branch>`, the operator resolves it manually.
- **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree on kill.

## Related code

| File | Purpose |
|---|---|
| `engine/shared.js` — `WORKTREE_MODES`, `validateWorktreeMode` | Enum + validator (P-a3f9b201). |
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, best-effort fetch, branch resolution (P-a3f9b203). |
| `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
| `engine.js` — dispatcher `liveProjectsInUse` set | Per-project mutating-concurrency cap (P-a3f9b205). |
| `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
| `dashboard/js/settings.js` — worktreeMode dropdown + chip | Operator-facing UI (P-a3f9b207). |
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
| `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY` from mechanical retry. |
| `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
