# Host-command concurrency — no two similar commands at once (HOST_EXEC §concurrency)

A lightweight coordination layer that stops two parallel agents from running **similar** host
commands at the same time — the canonical case being two simultaneous builds. Detection is fuzzy,
the conflicting session is named in chat, and the user can always override a false positive.

**Status:** built. Companion to LOCKING (file-edit locks) and ADAPTERS §`host_exec`. The Swift
surface (§5) is the stable contract.

---

## 0. Problem

Every `host_exec` call escapes the sandbox onto the **one** shared macOS host (ADAPTERS §`host_exec`).
File locks (LOCKING) don't help here: a build touches no *predicted-edit* files, so it never
acquires a lock. Two failure modes follow when agents run in parallel:

1. **Toolchain thrash.** Two `swift build`s (or any heavy build) racing on one machine each slow the
   other down and oversubscribe CPU/IO.
2. **Shared build directory (the sharp edge).** nvrsion sessions (NVRSION) share **one trunk
   worktree**, so two builds there write the *same* `.build`/derived-data directory and corrupt each
   other — not merely slow, but wrong.

The user's ask: before a host command runs, check whether a *similar* one is already running in
another session; if so, say **which** session, and offer an **override** in chat for false positives.

## 1. Invariants

1. **Coordinated, not locked.** This is advisory + user-gated, not a hard mutex. A command claims a
   *slot* for its duration; a conflict raises a prompt, never a silent block.
2. **Fuzzy, conservative comparison.** Similarity is decided by a pure, model-free detector
   (§3). It errs toward *not* flagging: a false negative just preserves today's behavior (both run),
   while a false positive is always escapable via the override — so the detector stays tight.
3. **Always escapable.** Every detected conflict surfaces an in-chat prompt that names the
   conflicting session and offers **Run anyway** (override) / **Cancel** (skip). The override is
   one-shot — never remembered — so a dismissed false positive doesn't suppress the next, different one.
4. **Fail toward progress.** With no coordinator attached (host-only/test setups), the gate is a
   no-op and the command just runs — mirroring LOCKING's fail-open `arbitrate`.
5. **Self-healing registry.** A session that vanishes mid-command (crash, interrupt) can never pin a
   phantom conflict: stale slots are pruned on the next claim and filtered at read time.

## 2. The slot registry

`AppStore` — the one object that knows every session — owns `runningHostCommands:
[SessionID: RunningHostCommand]` (`HostCommandConflict.swift`). Each entry is one session's
currently-running host command (`command` + captured `title` + `startedAt`). Reached from the
per-session backend through the shared `ConflictCoordinator` / `ConflictArbiter` seam — the **same
seam** `check_conflict` (file locks) uses, so there's no new AppStore↔backend coupling.

**Atomic claim.** `beginHostCommand` does prune → conflict-check → register with **no `await`
between the read and the write**, so two sessions racing into it are serialized on the MainActor:
the first claims its slot, the second then observes it. Without that, both could pass the check and run.

## 3. Fuzzy comparison (`HostCommandConflictDetector`)

Pure and deterministic (mirrors `ConflictDetector`). Reuses `CommandSummary.classify` for the
command word + coarse category, then layers a token net. **Comparison is scoped to one project**
(`conflicts` filters by `projectID`): collisions come from shared build state, which is per-project
(most sharply a shared trunk's build directory, NVRSION); two commands in *different* projects share
only host CPU, which we deliberately tolerate. Within a project, two commands conflict when **any**
holds:

1. **Identical** after whitespace/case normalization — unmistakably the same op.
2. **Both builds** — both classify to the `build` category (`swift`, `make`, `npm`, `cargo`, `gcc`,
   …), **regardless of toolchain**. Any two builds in one project contend: they thrash the toolchain
   and, for nvrsion sessions sharing one trunk, write the same build directory. So `swift build` vs
   `swift test`, *and* `swift build` vs `cargo build`, both flag — but only within the same project.
3. **Near-identical text** — token-set Jaccard ≥ `0.6` catches the same op with minor flag/arg
   differences even outside the modelled build category (e.g. re-running one script).

## 4. The gate (`ClaudeCodeBackend.handleHostExecCall`)

Host execution is a Claude-backend feature; the gate sits in its sole choke point, **after** the
normal host-exec approval and right before the command runs:

```
handleHostExecCall:
    …normal host-exec approval (unchanged)…            # independent of this gate
    override = false
    loop:
        switch beginHostCommand(session, command, override):
            case .clear:        reply = runOnHost(); endHostCommand(session); return reply
            case .conflict(cs): if surfaceOverridePrompt(cs):  override = true; continue   # "Run anyway"
                                else:                          return denied(cs)           # "Cancel"
```

- The conflict gate is **independent of host-exec trust**: it fires even when the user has chosen
  "Allow for Session," because "do I trust host exec" and "is the host busy with a twin" are
  different questions.
- **Skip message.** On Cancel the agent's tool result names the session and frames it as *wait and
  retry*, not a hard failure — so the agent coordinates rather than gives up.
- The slot is released the instant `runOnHost` returns (success, failure, or kill), so the window is
  exactly the command's runtime.

## 5. Swift surface (the stable contract)

```swift
// HostCommandConflict.swift
struct RunningHostCommand { sessionID; title; command; startedAt }
struct HostCommandConflict { sessionID; sessionTitle; command; reason }
enum   HostCommandClearance { case clear; case conflict([HostCommandConflict]) }
enum   HostCommandConflictDetector {
    static func similarity(_ a: String, _ b: String) -> String?            // reason, or nil
    static func conflicts(command:sessionID:against:) -> [HostCommandConflict]
}

// ConflictArbiter (AppStore conforms), forwarded through ConflictCoordinator (fail-open):
@MainActor func beginHostCommand(sessionID:command:override:) async -> HostCommandClearance
@MainActor func endHostCommand(sessionID:) async
```

## 6. UI

The override prompt reuses the approval pipeline (no new Risk case): the approval carries a
`host_command_conflict` payload (which session, its command, why). `ApprovalBar` renders it as a
`HostCommandConflictCard` — naming the conflicting session(s) and both commands — and swaps the
host-exec buttons for **Cancel** / **Run anyway**. iOS renders it as a generic approval (the title
still names the session); a dedicated card is additive follow-up.

## 7. Tests

`HostCommandConflictTests` covers the detector (identical / same-build-tool / different-toolchain /
near-identical / reads / empties / own-session exclusion / multi-session naming).
`ConflictCoordinatorTests.hostCommandFailsOpenWithoutArbiter` covers the fail-open seam.

## 8. Considerations / open questions

1. **Scope of "contended."** Today: any two builds (same project), or near-identical text. Could grow
   to cover test runners (`pytest`/`jest`) or long-lived servers if they prove to collide. 🟡
2. **Project scope.** Contention is per-project (§3); two projects' builds running at once aren't
   flagged, since they have independent build dirs and only share host CPU. Revisit if cross-project
   CPU thrash becomes a real complaint (would relax the `projectID` filter to host-wide). 🟡
3. **Queue instead of prompt?** v0 prompts (Run anyway / Cancel). A "wait until it finishes, then
   run" option (à la LOCKING's Wait-for-Access) is a natural follow-up. 🟡
