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]>
502 lines
20 KiB
Swift
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)
|
|
}
|
|
}
|