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:
- Toolchain thrash. Two
swift builds (or any heavy build) racing on one machine each slow the other down and oversubscribe CPU/IO. - 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
- 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.
- 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.
- 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.
- 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. - 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:
- Identical after whitespace/case normalization — unmistakably the same op.
- Both builds — both classify to the
buildcategory (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. Soswift buildvsswift test, andswift buildvscargo build, both flag — but only within the same project. - Near-identical text — token-set Jaccard ≥
0.6catches 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 pollsclaimQueuedHostCommandon 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'supdatedInput(HostCommandConflictSignal.waitInputKey), not a new genericDecisioncase — "wait" is meaningless for every other approval. - The slot is released the instant
runOnHostreturns (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:
- No running twin — nothing in
runningHostCommands(same project, excluding itself) issimilarity-similar to its command, and - 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
- 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. 🟡 - 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
projectIDfilter to host-wide). 🟡 - 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
(
hostCommandWaitQueueis already observable), and a transcript breadcrumb while a session waits. 🟡 - Waiter vs. fresh command ordering. The FIFO queue orders waiters against each other. A
brand-new
host_execstill goes throughbeginHostCommand, 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 requirebeginHostCommandto 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. 🟡