Detect edit conflicts automatically instead of relying on the agent

The agent-driven check_conflict tool was unreliable — models simply don't
call it (confirmed: zero calls across all test sessions). Replace the
dependency on model cooperation with automatic interception at the point
Nucleic already controls: the permission server.

When conflict coordination is active, claude always runs in `--permission-mode
default` so every tool call routes through our permission server (Claude's
`auto` mode auto-accepts edits before we can see them — confirmed empirically),
and Nucleic reproduces auto-approve itself (allow all but destructive). Every
Edit/Write/NotebookEdit is conflict-checked against other active sessions'
worktree footprints before it lands; on overlap the Defer/Cancel/Override sheet
blocks the edit. Path normalization handles sandboxed (canonicalized) worktrees.

The check_conflict MCP tool is kept as a secondary explicit path, but the
system-prompt instruction is dropped (no longer needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-06-12 23:58:46 -07:00
co-authored by Claude Opus 4.8
parent 40b291a651
commit 28282471d9
3 changed files with 81 additions and 31 deletions
+13 -4
View File
@@ -738,10 +738,14 @@ public final class AppStore: ConflictArbiter {
let matches = ConflictDetector.detect(candidate, against: active)
guard !matches.isEmpty else { return .noConflict }
// Edit interception passes no task text; fall back to the session's title (its
// objective) so the prompt and any filed to-do are meaningful.
let promptTask = trimmedTask.isEmpty
? (callerSession?.title ?? "this agent's work") : trimmedTask
let prompt = ConflictPrompt(
id: UUID().uuidString, sessionID: sessionID, projectID: callerSession?.projectID,
sessionTitle: callerSession?.title ?? "an agent",
task: trimmedTask.isEmpty ? "(unnamed task)" : trimmedTask, matches: matches)
task: promptTask, matches: matches)
return await withCheckedContinuation { continuation in
conflictQueue.append((prompt, continuation))
if pendingConflict == nil { pendingConflict = prompt }
@@ -787,10 +791,15 @@ public final class AppStore: ConflictArbiter {
}
/// Reduce an agent-supplied path to repo-relative by dropping its worktree prefix if
/// present (agents may report absolute paths inside their own worktree).
/// present (agents report absolute paths inside their own worktree). Tries both the raw
/// stored path and its canonical form, since a sandboxed agent's cwd is the canonicalized
/// (symlink-resolved) container path, not the raw host path.
private func stripWorktreePrefix(_ path: String, _ worktree: String?) -> String {
guard let worktree, !worktree.isEmpty, path.hasPrefix(worktree) else { return path }
return String(path.dropFirst(worktree.count))
guard let worktree, !worktree.isEmpty else { return path }
for prefix in [worktree, GitWorktreeManager.canonical(worktree)] where path.hasPrefix(prefix) {
return String(path.dropFirst(prefix.count))
}
return path
}
// MARK: - Dashboard
@@ -86,6 +86,12 @@ public actor ClaudeCodeBackend: AgentBackend {
private var interruptRequested = false
private var terminating = false
private var sawRunFinished = false
/// Whether the current run is in auto-approve mode (set per run). Edit tools are routed
/// through us even in auto mode for a conflict pre-check; on no-conflict they auto-allow.
private var autoApprove = false
/// Set once the user chooses "Proceed Anyway" on a conflict this run, so subsequent edits
/// in the same turn don't re-prompt for the same overridden conflict.
private var conflictOverridden = false
public private(set) var backendSessionID: String?
public init(
@@ -104,18 +110,6 @@ public actor ClaudeCodeBackend: AgentBackend {
self.conflictCoordinator = conflictCoordinator
}
/// System-prompt addendum that teaches the agent the conflict-check protocol. Appended
/// only when a `conflictCoordinator` is wired (so plain test backends are unaffected).
static let conflictProtocolPrompt = """
Nucleic runs multiple agents in parallel, each in its own git worktree. Before you \
begin a distinct task that will edit files, call the `check_conflict` tool \
(mcp__nucleic__check_conflict) with a one-line `task` description and the \
repo-relative `files` (or directories) you expect to change. If the result's \
`action` is "deferred" or "cancelled", STOP immediately and do not edit anything — \
relay the message to the user. If it is "proceed" or there is no conflict, continue \
as normal. Skip the check for read-only investigation or trivial single-line tweaks.
"""
// MARK: - AgentBackend
public nonisolated func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
@@ -188,6 +182,8 @@ public actor ClaudeCodeBackend: AgentBackend {
self.sessionID = run.sessionID
self.sawRunFinished = false
self.interruptRequested = false
self.autoApprove = run.autoApprove
self.conflictOverridden = false
do {
// 0. Sandbox: if this run is containerized, bring the per-session container up
@@ -234,12 +230,15 @@ public actor ClaudeCodeBackend: AgentBackend {
}
args += ["--permission-prompt-tool", MCPApprovalServer.qualifiedToolName]
// Pin the mode explicitly: the host machine's defaultMode would otherwise
// leak in (M0 live-capture finding, 2026-06-12). In auto mode, Claude's
// own classifier auto-approves safe actions and routes the risky/
// destructive ones to our permission tool; otherwise everything routes to
// us (manual approvals).
// leak in (M0 live-capture finding, 2026-06-12). With conflict coordination we
// force `default` so EVERY tool call flows through our permission server (the
// only way to conflict-check edits Claude's `auto` mode auto-accepts edits
// before we ever see them, confirmed empirically); we then reproduce auto-approve
// ourselves in `handleApprovalCall`. Without coordination, defer to Claude's own
// `auto` classifier as before.
if case .interactive = run.approvalPolicy {
args += ["--permission-mode", run.autoApprove ? "auto" : "default"]
let mode = (conflictCoordinator == nil && run.autoApprove) ? "auto" : "default"
args += ["--permission-mode", mode]
}
args += ["--mcp-config", mcpConfig]
// Hermetic child: only our approval server, none of the host's global
@@ -250,8 +249,8 @@ public actor ClaudeCodeBackend: AgentBackend {
if let settings = configuration.settings {
args += ["--settings", settings]
}
// Pre-allow `check_conflict` so consulting it never round-trips through the
// approval prompt (it's a coordination query, not a gated action).
// Pre-allow `check_conflict` so an agent that consults it directly never
// round-trips through the approval prompt (it's a query, not a gated action).
var allowedTools = configuration.allowedTools
if conflictCoordinator != nil {
allowedTools.append(MCPApprovalServer.qualifiedConflictToolName)
@@ -261,14 +260,8 @@ public actor ClaudeCodeBackend: AgentBackend {
}
if let model = run.model { args += ["--model", model] }
if let effort = run.effort { args += ["--effort", effort] }
// Combine the caller's system-prompt addendum (if any) with the conflict
// protocol instruction (only when conflict coordination is wired).
let systemPrompts = [
run.appendSystemPrompt,
conflictCoordinator != nil ? Self.conflictProtocolPrompt : nil,
].compactMap { $0 }
if !systemPrompts.isEmpty {
args += ["--append-system-prompt", systemPrompts.joined(separator: "\n\n")]
if let appendSystemPrompt = run.appendSystemPrompt {
args += ["--append-system-prompt", appendSystemPrompt]
}
args += resumeArgs
args += run.extraArgs
@@ -371,11 +364,46 @@ public actor ClaudeCodeBackend: AgentBackend {
private func handleApprovalCall(_ call: MCPApprovalServer.ApprovalCall) async
-> MCPApprovalServer.Reply
{
// Conflict pre-check (internal project management): edit-class tools are routed here
// even in auto mode. Before the edit lands, ask Nucleic whether the target file
// overlaps another active agent's work. Runs ahead of the always-rule cache so an
// "always allow" rule can't slip a conflicting edit past.
let editPaths = RiskClassifier.editedPaths(toolName: call.toolName, input: call.input)
if let conflictCoordinator, !editPaths.isEmpty, !conflictOverridden {
let resolution = await conflictCoordinator.arbitrate(
sessionID: sessionID ?? SessionID(rawValue: "unknown"), task: "", files: editPaths)
switch resolution {
case .deferred:
return .deny(message:
"This edit conflicts with another active Nucleic agent and was added to the "
+ "to-do list. Stop now and do not edit — tell the user it was deferred.")
case .cancelled:
return .deny(message:
"This edit conflicts with another active Nucleic agent and the user cancelled "
+ "it. Stop now and do not edit.")
case .proceed:
// User chose to override; don't re-prompt for further edits this turn.
conflictOverridden = true
case .noConflict:
break
}
}
// Always-rule short-circuit: auto-answer without surfacing (RUNTIME §5).
if let cached = await approvals.cachedDecision(toolName: call.toolName, input: call.input) {
return ClaudeDecisionMapping.reply(for: cached.decision, originalInput: call.input)
}
// Nucleic-managed auto-approve: conflict coordination forces `default` mode (every
// call routes here), so we reproduce auto mode ourselves approve everything except
// destructive actions, which still surface for explicit approval. The edit conflict
// pre-check above already ran, so an auto-approved edit is conflict-clean.
if conflictCoordinator != nil, autoApprove,
RiskClassifier.classify(toolName: call.toolName, input: call.input) != .destructive
{
return ClaudeDecisionMapping.reply(for: .allow(), originalInput: call.input)
}
let request = ApprovalRequest(
id: .generate(),
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
+13
View File
@@ -21,6 +21,19 @@ public enum RiskClassifier {
}
}
/// The file path(s) an edit-class tool call targets, for conflict detection. Empty for
/// non-editing tools. Edit/Write/MultiEdit use `file_path`; NotebookEdit uses `notebook_path`.
public static func editedPaths(toolName: String, input: JSONValue) -> [String] {
switch toolName {
case "Edit", "Write", "MultiEdit":
return [input["file_path"]?.stringValue].compactMap { $0 }
case "NotebookEdit":
return [input["notebook_path"]?.stringValue].compactMap { $0 }
default:
return []
}
}
private static func classifyCommand(_ command: String) -> Risk {
let destructive: [String] = [
"rm ", "rm\t", "rmdir", "sudo ", "mkfs", "dd ", "shutdown", "reboot",