Files
nucleic/docs/HOST_EXEC_CONCURRENCY.md
T

13 KiB

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 builds (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): switch surfaceOverridePrompt(cs):
                case runAnyway: override = true; continue                    # "Run anyway"
                case wait:      return waitForHostCommandSlot(session)       # "Wait" (FIFO §4.1)
                case skip:      return denied(cs)                            # "Cancel"

waitForHostCommandSlot:                                # after "Wait"
    enqueueHostCommandWait(session, command)           # join the FIFO queue
    loop:
        if interrupted: dequeueHostCommandWait(session); return cancelled   # Stop bails cleanly
        switch claimQueuedHostCommand(session, command):
            case .clear:    reply = runOnHost(); endHostCommand(session); return reply
            case .conflict: sleep(pollInterval); continue                   # twin still running
  • 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.
  • Wait (FIFO). On Wait the session joins a per-project FIFO queue (hostCommandWaitQueue) and polls claimQueuedHostCommand on a 1 s interval. It claims only when (1) no running twin still conflicts and (2) no earlier waiter it contends with is still ahead of it — so two builds that both chose Wait run in the order they queued, while an independent read waiting behind them isn't held back. Pressing Stop dequeues cleanly, so a bailed waiter never pins the line. The signal is carried on the approval decision's updatedInput (HostCommandConflictSignal.waitInputKey), not a new generic Decision case — "wait" is meaningless for every other approval.
  • The slot is released the instant runOnHost returns (success, failure, or kill), so the window is exactly the command's runtime.

4.1 The FIFO wait queue

AppStore.hostCommandWaitQueue: [QueuedHostCommand] is an ordered list — front = next in line — that a waiter joins via enqueueHostCommandWait and leaves on claim or dequeueHostCommandWait. Ordering is array position (arrival order), not a timestamp, so the FIFO invariant is structural.

Who's next (queuedWaiterMayClaim). Pure and deterministic, mirroring conflicts. A waiter may claim iff:

  1. No running twin — nothing in runningHostCommands (same project, excluding itself) is similarity-similar to its command, and
  2. No earlier contender — no entry before it in the queue (same project) holds a similar command.

(2) is what makes it FIFO for contending work while leaving independent commands free: a plain cat queued behind two builds contends with neither the running set nor the earlier builders, so it claims immediately; the second build waits for the first. When the leader finishes and releases its slot, the next contender's following poll sees a free slot and an empty lead, and claims.

Atomicity. claimQueuedHostCommand does prune → check → register with no await between the read and the write (as beginHostCommand does), so two waiters polling at once are serialized on the MainActor: exactly one claims, the other observes it and keeps waiting.

Self-healing. Dead sessions are pruned from both the queue and the running set on every beginHostCommand/claimQueuedHostCommand, so a crashed waiter or runner can't wedge the line.

5. Swift surface (the stable contract)

// HostCommandConflict.swift
struct RunningHostCommand { sessionID; title; command; startedAt }
struct HostCommandConflict { sessionID; sessionTitle; command; reason }
struct QueuedHostCommand  { sessionID; projectID; command; enqueuedAt }    // a FIFO waiter (§4.1)
enum   HostCommandClearance { case clear; case conflict([HostCommandConflict]) }
enum   HostCommandConflictSignal { static let waitInputKey }               // "Wait" decision flag
enum   HostCommandConflictDetector {
    static func similarity(_ a: String, _ b: String) -> String?            // reason, or nil
    static func conflicts(command:sessionID:projectID:against:) -> [HostCommandConflict]
    static func queuedWaiterMayClaim(sessionID:command:projectID:running:queue:) -> Bool  // FIFO
}

// ConflictArbiter (AppStore conforms), forwarded through ConflictCoordinator (fail-open):
@MainActor func beginHostCommand(sessionID:command:override:) async -> HostCommandClearance
@MainActor func endHostCommand(sessionID:) async
@MainActor func enqueueHostCommandWait(sessionID:command:) async                             // Wait
@MainActor func claimQueuedHostCommand(sessionID:command:) async -> HostCommandClearance     // poll
@MainActor func dequeueHostCommandWait(sessionID:) async                                     // bail

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 / Wait / Run anyway. Wait replies with a plain .allow tagged by HostCommandConflictSignal.waitInputKey in updatedInput, which the gate reads to queue rather than override (§4). iOS renders it as a generic approval (the title still names the session) with only Allow/Deny, i.e. Run-anyway/Cancel; a dedicated card with Wait 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) and the FIFO queuedWaiterMayClaim (blocked-while-twin-runs / claims-once-clear / FIFO order between contenders / independent waiter not held back / per-project fairness). ConflictDetectorTests.hostCommandFailsOpenWithoutArbiter and queuedHostCommandFailsOpenWithoutArbiter cover the fail-open seam for both the claim and the wait-queue methods.

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? Built. The prompt now offers Wait alongside Run-anyway / Cancel: the session joins a per-project FIFO queue and auto-runs once the twin finishes (§4, §4.1), à la LOCKING's Wait-for-Access. Remaining polish: surfacing "N waiting" in the UI (hostCommandWaitQueue is already observable), and a transcript breadcrumb while a session waits. 🟡
  4. Waiter vs. fresh command ordering. The FIFO queue orders waiters against each other. A brand-new host_exec still goes through beginHostCommand, which only consults running commands — so in the sub-second window after a twin finishes but before a parked waiter's next 1 s poll, a freshly-arriving command could claim the just-freed host ahead of the waiter. The waiter isn't starved (it takes the slot the moment that command ends and it's at the head), but strict global FIFO would require beginHostCommand to also yield to queued waiters. Deferred: the fix needs a conflict payload that can name a queued (not yet running) blocker, and the race is narrow. 🟡