Files
nucleic/Sources/NucleicApp/ControlPanelView.swift
T
abkslmandClaude Opus 4.8 38664a02b2 Autoship: lock every edited file + keep autoship armed on a merge conflict
Three fixes to the Nucleic Control autoship/file-lock system.

1. File locking now covers every edit in a turn, not just the first.
   handleApprovalCall arbitrated each edit only while !conflictOverridden,
   and a successful lock grant (.granted -> .proceed) latched that flag, so
   after the first edited file every later edit skipped arbitration and
   acquired no lock. Parallel sessions could then edit the same file and
   collide at autoship time. The latch was a leftover from the old
   interactive-prompt model (arbitrate is now automatic and never prompts);
   removed it so every edit re-arbitrates. Re-arbitrating a file the session
   already holds is a cheap LockManager fast-path no-op.

2. A merge *conflict* no longer turns autoship off. It sets a sticky
   autoShipConflict marker, leaves autoship armed (so the work re-ships on
   the next clean merge), and is flagged prominently: the sidebar
   "needs attention" icon, the header/Control-panel ship status, an in-chat
   note, and lastError. A hard merge *error* still disarms via autoShipFailed.

3. When a conflict's work finally lands -- the queue's next merge or the
   agent's own merge observed via the git shim -- the conflict marker clears
   and the live ship status updates to .merged, so the UI recovers. Fixes the
   stuck state where, after a conflict turned autoship off, the agent's own
   merge appeared in the activity feed but markShippedIfLanded bailed (it
   required autoShip) and the Control panel kept showing the stale conflict.

Adds a session column (auto_ship_conflict, migration v19), SessionController
mutators, UI wiring, and tests.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 05:12:37 -07:00

502 lines
20 KiB
Swift

import SwiftUI
import NucleicCore
/// The sidebar "Control" mode (see `SidebarMode`): a cross-session view of the whole Nucleic
/// Control subsystem, gathered from `AppStore.controlSnapshot()`. Four stacked sections —
///
/// 1. **Container** — health of the shared `nucleic-control` sandbox (running/stopped, resources,
/// how many Control sessions and projects it serves).
/// 2. **Autoship** — each Control session's merge-queue state (armed → queued → merging →
/// shipped / failed).
/// 3. **File locks** — which sessions hold a lock on each file and which are queued behind them
/// (with the deadlock banner + release controls), exactly as the old Interlock pane.
/// 4. **Activity** — the git-interceptor feed: the merges/commits/rebases the in-container shim
/// observed with certainty, newest-first.
///
/// All four are Control-only by design (locking, autoship, the container, and the interceptor are
/// one bundle), so this panel is the single place that surfaces them. It polls while shown — the
/// container probe and lock footprints aren't observable — and the loop cancels on a mode switch.
struct SidebarControlPanel: View {
/// Cap for the card list — eight session cards, matching the Recents section. The pane
/// shrinks to fit fewer cards and scrolls within this when there are more (see `SidebarPane`).
let bodyHeight: CGFloat
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@State private var snapshot: ControlSnapshot?
@State private var confirmingReleaseAll = false
var body: some View {
SidebarPane(icon: "atom", summary: summary, maxBodyHeight: bodyHeight) {
releaseAllButton
} content: {
content
}
.task { await poll() }
.confirmationDialog(
"Release all file locks?",
isPresented: $confirmingReleaseAll, titleVisibility: .visible
) {
Button("Release All Locks", role: .destructive) { releaseAll() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Clears every session's hold on its changed files so all queued agents "
+ "can proceed. Each lock re-arms once that session merges or edits again.")
}
}
// Poll the aggregate snapshot while the pane is open. Cancels automatically on dismiss.
private func poll() async {
while !Task.isCancelled {
snapshot = await store.controlSnapshot()
try? await Task.sleep(for: .seconds(2))
}
}
// MARK: - Header
/// One-line health rollup for the pane header: container state plus any live lock/ship counts.
private var summary: String {
guard let snapshot else { return "Loading…" }
let c = snapshot.container
if !c.serviceEnabled { return "Container service off" }
var bits = [c.running ? "Container running" : (c.exists ? "Container stopped" : "No container")]
let holders = holderIDs(snapshot).count
if holders > 0 {
let n = snapshot.locks.files.count
bits.append("\(holders) holding \(n) file\(n == 1 ? "" : "s")")
}
let inFlight = snapshot.autoship.filter { $0.isInFlight }.count
if inFlight > 0 { bits.append("\(inFlight) shipping") }
return bits.joined(separator: " · ")
}
/// Sessions currently holding at least one file lock.
private func holderIDs(_ snapshot: ControlSnapshot) -> [SessionID] {
var seen = Set<SessionID>()
return snapshot.locks.files
.flatMap { $0.holders.map(\.sessionID) }
.filter { seen.insert($0).inserted }
}
/// Force-releases every session's locks at once. Shown only while some session is holding one.
@ViewBuilder
private var releaseAllButton: some View {
if let snapshot, !holderIDs(snapshot).isEmpty {
Button("Release All", systemImage: "lock.open") {
confirmingReleaseAll = true
}
.buttonStyle(.borderless).font(.caption).controlSize(.small)
.help("Force-release every session's file locks so all queued agents can proceed")
}
}
// MARK: - Body
@ViewBuilder
private var content: some View {
if let snapshot {
VStack(alignment: .leading, spacing: 16) {
containerSection(snapshot.container)
if !snapshot.autoship.isEmpty { autoshipSection(snapshot.autoship) }
locksSection(snapshot.locks)
if !snapshot.activity.isEmpty { activitySection(snapshot.activity) }
if !snapshot.commands.isEmpty { commandsSection(snapshot.commands) }
if isQuiet(snapshot) { quietNote }
}
// No top inset — the header provides the standard gap below the divider.
.padding(.bottom, 12).padding(.horizontal, 8)
} else {
SidebarPanePlaceholder { ProgressView() }
}
}
/// True when nothing is happening beyond the container itself — no locks, ships, or activity.
private func isQuiet(_ snapshot: ControlSnapshot) -> Bool {
snapshot.autoship.isEmpty && snapshot.locks.isEmpty && snapshot.activity.isEmpty
&& snapshot.commands.isEmpty && store.deadlockedSessions.isEmpty
}
private var quietNote: some View {
Text("No autoship, file locks, or command activity yet.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
}
// MARK: - Container
@ViewBuilder
private func containerSection(_ c: ControlContainerInfo) -> some View {
section("Container") {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle().fill(containerColor(c)).frame(width: 8, height: 8)
Text(c.name).font(.callout).lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text(containerStatusText(c)).font(.caption2).foregroundStyle(.secondary)
}
if c.serviceEnabled {
HStack(spacing: 10) {
Label("\(c.cpus) CPU", systemImage: "cpu")
Label("\(c.memoryGiB) GB", systemImage: "memorychip")
Label("\(c.activeSessions) active", systemImage: "bubble.left.and.bubble.right")
}
.font(.caption2).foregroundStyle(.secondary)
.labelStyle(.titleAndIcon).lineLimit(1)
} else {
Text("Nucleic Control needs the container service. Enable it in Settings → Sandbox.")
.font(.caption2).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.vertical, 4)
}
}
private func containerColor(_ c: ControlContainerInfo) -> Color {
if !c.serviceEnabled { return .secondary }
if c.running { return palette.success }
if c.exists { return palette.attention }
return .secondary
}
private func containerStatusText(_ c: ControlContainerInfo) -> String {
if !c.serviceEnabled { return "Service off" }
if c.running { return "Running" }
if c.exists { return "Stopped" }
return "Not created"
}
// MARK: - Autoship
@ViewBuilder
private func autoshipSection(_ entries: [AutoshipEntry]) -> some View {
section("Autoship") {
ForEach(entries) { e in
let v = autoshipVisual(e)
ControlInfoRow(
symbol: v.symbol, tint: v.tint, title: e.title, subtitle: v.line,
onOpen: { open(e.id) })
}
}
}
/// Glyph, tint, and status line for one autoship entry, mirroring `handleShipUpdate`'s notes.
private func autoshipVisual(_ e: AutoshipEntry) -> (symbol: String, tint: Color, line: String) {
switch e.status {
case nil:
return ("shippingbox", .secondary, "Armed → \(e.target)")
case .queued(let position):
let where_ = position == 0 ? "Next to ship" : "Queued #\(position + 1)"
return ("clock", palette.attention, "\(where_)\(e.target)")
case .merging:
return ("arrow.triangle.merge", palette.accent, "Merging → \(e.target)…")
case .merged(let commit):
// Commit is empty for an agent-performed merge observed in-container (no SHA on hand);
// omit the "· <sha>" suffix in that case.
let sha = commit.isEmpty ? "" : " · \(commit.prefix(7))"
return ("checkmark.seal.fill", palette.success, "Shipped → \(e.target)\(sha)")
case .conflicted(let paths):
let list = paths.prefix(2).joined(separator: ", ")
let more = paths.count > 2 ? "…" : ""
return ("exclamationmark.triangle.fill", palette.attention,
"Conflict in \(list)\(more) — needs attention (still armed)")
case .failed(let reason):
return ("exclamationmark.triangle.fill", palette.attention, "Failed — \(reason)")
case .skipped:
return ("minus.circle", .secondary, "Nothing to ship")
}
}
// MARK: - File locks
@ViewBuilder
private func locksSection(_ locks: LockQueueSnapshot) -> some View {
if !store.deadlockedSessions.isEmpty {
deadlockBanner
}
if !locks.files.isEmpty {
section("File locks") {
ForEach(locks.files) { lock in
FileLockCard(lock: lock, onOpen: open, onRelease: release)
}
}
}
// Waiters with no predicted files won't appear under any file above; surface them here.
let unfiled = locks.waiters.filter { $0.wantedFiles.isEmpty }
if !unfiled.isEmpty {
section("Queued (no specific file)") {
ForEach(unfiled) { waiter in
WaiterCard(waiter: waiter, onOpen: open)
}
}
}
}
/// Shown only when the lock manager finds a wait-for cycle the queue can never clear on its own.
private var deadlockBanner: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(palette.attention)
let n = store.deadlockedSessions.count
Text("Deadlock: \(n) session\(n == 1 ? "" : "s") are waiting on each other's locks. "
+ "Release one holder below to break the cycle.")
.font(.caption)
Spacer(minLength: 0)
}
.padding(.horizontal, 8).padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.attention.opacity(0.12))
}
// MARK: - Activity
@ViewBuilder
private func activitySection(_ events: [GitInterceptorEvent]) -> some View {
section("Activity") {
ForEach(events) { e in
let failed = !e.succeeded
ControlInfoRow(
symbol: failed ? "exclamationmark.triangle" : e.symbol,
tint: failed ? palette.attention : .secondary,
title: e.label,
subtitle: failed ? "\(e.sessionTitle) · failed" : e.sessionTitle,
trailing: Self.relative(e.at),
onOpen: { open(e.sessionID) })
}
}
}
// MARK: - Commands
@ViewBuilder
private func commandsSection(_ events: [CommandInterceptorEvent]) -> some View {
section("Commands") {
ForEach(events) { e in
let failed = !e.succeeded
ControlInfoRow(
symbol: failed ? "exclamationmark.triangle" : e.symbol,
tint: failed ? palette.attention : (e.isMutating ? palette.accent : .secondary),
title: e.label,
subtitle: Self.commandSubtitle(e),
trailing: Self.relative(e.at),
onOpen: { open(e.sessionID) })
}
}
}
/// The command row's single meta line: session · duration · output preview (or `failed`),
/// kept to one line so every row in the section is the same fixed height.
private static func commandSubtitle(_ e: CommandInterceptorEvent) -> String {
var parts: [String] = [e.sessionTitle]
if let ms = e.durationMs { parts.append(durationText(ms)) }
if !e.succeeded { parts.append("exit \(e.exitCode)") }
if !e.outputPreview.isEmpty { parts.append(e.outputPreview) }
return parts.joined(separator: " · ")
}
private static func durationText(_ ms: Int) -> String {
ms < 1000 ? "\(ms)ms" : String(format: "%.1fs", Double(ms) / 1000)
}
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
return f
}()
private static func relative(_ date: Date) -> String {
relativeFormatter.localizedString(for: date, relativeTo: Date())
}
// MARK: - Shared chrome + actions
@ViewBuilder
private func section(_ title: String, @ViewBuilder _ body: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title.uppercased())
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
body()
}
}
private func open(_ sessionID: SessionID) {
store.openSessionID = sessionID
}
private func release(_ sessionID: SessionID) {
Task {
await store.forceReleaseLocks(sessionID)
snapshot = await store.controlSnapshot()
}
}
private func releaseAll() {
guard let snapshot else { return }
let ids = holderIDs(snapshot)
Task {
for id in ids { await store.forceReleaseLocks(id) }
self.snapshot = await store.controlSnapshot()
}
}
}
private extension AutoshipEntry {
/// Queued or actively merging — the states that count toward the header's "shipping" tally.
var isInFlight: Bool {
switch status {
case .queued, .merging: return true
default: return false
}
}
}
/// A compact session-style row shared by the Autoship and Activity sections: a small leading
/// status glyph centered against the row, a title line, an optional dimmer subtitle, and an
/// optional trailing note (e.g. a relative timestamp). Tapping opens the responsible chat.
private struct ControlInfoRow: View {
let symbol: String
let tint: Color
let title: String
var subtitle: String?
var trailing: String?
let onOpen: () -> Void
var body: some View {
HStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 12)
VStack(alignment: .leading, spacing: 1) {
Text(title).lineLimit(1).truncationMode(.middle)
if let subtitle, !subtitle.isEmpty {
Text(subtitle).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer(minLength: 8)
if let trailing {
Text(trailing).font(.caption2).foregroundStyle(.tertiary).fixedSize()
}
}
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { onOpen() }
}
}
/// One locked file, rendered as session-style rows — one per participant. Each row shows a
/// small lock symbol (green = holding, amber hourglass = waiting) centered against the row,
/// the file name as the title, and the chat that holds (or waits for) it beneath.
private struct FileLockCard: View {
@Environment(\.appPalette) private var palette
let lock: FileLock
let onOpen: (SessionID) -> Void
let onRelease: (SessionID) -> Void
private var filename: String { (lock.path as NSString).lastPathComponent }
var body: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(lock.holders) { holder in
LockEntryRow(
title: filename, subtitle: holder.title, fullPath: lock.path,
icon: "lock.fill", tint: palette.success,
onOpen: { onOpen(holder.sessionID) },
onRelease: { onRelease(holder.sessionID) })
}
ForEach(lock.waiters) { waiter in
LockEntryRow(
title: filename, subtitle: waiter.title, fullPath: lock.path,
icon: "hourglass", tint: palette.attention,
indent: 16,
onOpen: { onOpen(waiter.sessionID) },
onRelease: nil)
}
}
}
}
/// A single lock entry styled to match `SessionRow`: a small leading status symbol centered
/// vertically against the whole row, a title line, and a dimmer subtitle line. Tapping opens
/// the chat; holders also get a "Release Lock" context action. The symbol + color carry the
/// holding/waiting distinction, so there's no role text.
private struct LockEntryRow: View {
let title: String
/// `nil` hides the second line (e.g. a queued session with nothing to add).
var subtitle: String?
/// Full path for the tooltip, since the title shows only the file name.
var fullPath: String?
let icon: String
let tint: Color
/// Leading indent, used to nest waiting rows under the file their holder locked.
var indent: CGFloat = 0
let onOpen: () -> Void
/// Holders only — drives the trailing "Release" button and context action.
var onRelease: (() -> Void)?
var body: some View {
HStack(spacing: 6) {
// Sized and centered like SessionRow's status dot so the rows align.
Image(systemName: icon)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 8)
VStack(alignment: .leading, spacing: 1) {
Text(title).lineLimit(1).truncationMode(.middle)
if let subtitle, !subtitle.isEmpty {
Text(subtitle).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer(minLength: 8)
if let onRelease {
Button("Release") { onRelease() }
.buttonStyle(.borderless).font(.caption)
.help("Force-release this session's lock so queued agents can proceed. "
+ "It re-arms once the session merges or edits again.")
}
}
// No outer horizontal padding — the panel's card chunk already insets by 8, lining
// these rows up with the section title and the session rows below. `indent` nudges
// waiting rows to the right so they read as queued under their file.
.padding(.leading, indent)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 1)
.contentShape(Rectangle())
.onTapGesture { onOpen() }
.help(fullPath ?? "")
.contextMenu {
Button("Open Chat") { onOpen() }
if let onRelease {
Button("Release Lock", role: .destructive) { onRelease() }
}
}
}
}
/// A queued session that predicted no specific file (it matched another agent's work by
/// intent). Same session-style row, with the chat name as the title and what's blocking it
/// (or its task) beneath.
private struct WaiterCard: View {
@Environment(\.appPalette) private var palette
let waiter: WaitingSession
let onOpen: (SessionID) -> Void
private var subtitle: String? {
if !waiter.blockedBy.isEmpty {
return "Blocked by " + waiter.blockedBy.map(\.title).joined(separator: ", ")
}
return waiter.task.isEmpty ? nil : waiter.task
}
var body: some View {
LockEntryRow(
title: waiter.title, subtitle: subtitle, fullPath: nil,
icon: "hourglass", tint: palette.attention,
onOpen: { onOpen(waiter.sessionID) }, onRelease: nil)
}
}