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:
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user