Merge nucleic/zesty-umber-lemur-ciqf into dev

This commit is contained in:
2026-08-03 14:42:55 -07:00
parent 1ab77632ab
commit 9b6ae49420
3 changed files with 191 additions and 59 deletions
+87 -57
View File
@@ -2774,11 +2774,7 @@ struct SessionDetailView: View {
.listRowSeparator(.hidden)
}
private var hasTranscriptTrailingRows: Bool {
if isBusy { return true }
guard let id = session?.id, store.openHostID == nil else { return false }
return store.lockWaits[id] != nil
}
private var hasTranscriptTrailingRows: Bool { isBusy }
@ViewBuilder
private func transcriptPreamble(reserveCardSpace: Bool) -> some View {
@@ -2825,16 +2821,19 @@ struct SessionDetailView: View {
if isBusy {
WorkingIndicator(text: progressText).id(Self.workingID)
}
// A chat parked in the file-lock queue (or caught in a lock deadlock) must explain itself
// here, where the user is watching it "hang". Local only: peer lock state is not mirrored.
if let id = session?.id, store.openHostID == nil, let wait = store.lockWaits[id] {
LockContentionBanner(
wait: wait,
deadlocked: store.deadlockedSessions.contains(id),
onReleaseHolder: { holder in
Task { await store.forceReleaseLocks(holder.sessionID) }
})
}
}
/// What the open chat is blocked on in the file-lock queue, when it is drives the
/// contention card in the composer's glass. Local only: peer lock state is not mirrored.
private var openLockWait: AppStore.LockWaitInfo? {
guard let id = session?.id, store.openHostID == nil else { return nil }
return store.lockWaits[id]
}
/// Whether the composer's reveal slot has anything to show a permission/question request,
/// a lock-contention card, or both.
private var hasComposerRevealContent: Bool {
store.openApprovals.first != nil || openLockWait != nil
}
/// A real trailing element and fixed scroll target. Kept as its own final stack child so the
@@ -3028,34 +3027,52 @@ struct SessionDetailView: View {
return .black.opacity(0.42)
}
/// The pane's floating bottom furniture. A pending permission/question is rendered inside the
/// composer's glass shell, so it reads as the composer growing an input section rather than a
/// separate card appearing above it. The whole shell is measured as one block the height the
/// transcript reserves beneath its last message and anchors its fade to so the transcript
/// stays in step throughout the grow/collapse animation.
/// The pane's floating bottom furniture. A pending permission/question and a chat parked in
/// the file-lock queue is rendered inside the composer's glass shell, so it reads as the
/// composer growing an input section rather than a separate card appearing above it. The whole
/// shell is measured as one block the height the transcript reserves beneath its last message
/// and anchors its fade to so the transcript stays in step throughout the grow/collapse
/// animation.
private var floatingBottomCluster: some View {
VStack(spacing: 0) {
// This slot remains mounted while its child transitions, so the fade mask stays fixed
// at the composer's top edge instead of travelling with the request contents.
ZStack(alignment: .bottom) {
if let approval = store.openApprovals.first {
Group {
if approval.toolName == AskUserQuestion.toolName,
let questions = AskUserQuestion.questions(from: approval.input)
{
AskUserQuestionBar(request: approval, questions: questions)
} else {
ApprovalBar(request: approval)
}
// Both cards share one reveal slot, stacked, so a lock wait that coincides with a
// permission request grows the same glass instead of opening a second surface.
VStack(spacing: 8) {
// A chat parked in the file-lock queue (or caught in a lock deadlock) must
// explain itself where the user is about to type into what looks like a
// frozen chat beside the send button, not buried at the transcript's tail.
if let wait = openLockWait, let id = session?.id {
LockContentionBanner(
wait: wait,
deadlocked: store.deadlockedSessions.contains(id),
onReleaseHolder: { holder in
Task { await store.forceReleaseLocks(holder.sessionID) }
})
.transition(ComposerMotion.inputTransition(reduceMotion))
}
if let approval = store.openApprovals.first {
Group {
if approval.toolName == AskUserQuestion.toolName,
let questions = AskUserQuestion.questions(from: approval.input)
{
AskUserQuestionBar(request: approval, questions: questions)
} else {
ApprovalBar(request: approval)
}
}
.id(approval.id)
.transition(ComposerMotion.inputTransition(reduceMotion))
}
// The bottom inset is both breathing room and a clear reveal runway. At rest
// the card ends before the gradient; in motion its contents pass through it.
.padding(.horizontal, 8)
.padding(.top, 8)
.padding(.bottom, 8)
.id(approval.id)
.transition(ComposerMotion.inputTransition(reduceMotion))
}
// The bottom inset is both breathing room and a clear reveal runway. At rest
// the card ends before the gradient; in motion its contents pass through it.
// Dropped to zero when the slot is empty, so an idle composer's glass is exactly
// the composer the insets belong to the cards, not to the slot.
.padding(.horizontal, hasComposerRevealContent ? 8 : 0)
.padding(.vertical, hasComposerRevealContent ? 8 : 0)
}
// Keep the reveal slot at the composer's full width even when it has no child. Only
// its height can now change, so the outer glass grows straight upward instead of the
@@ -3086,6 +3103,10 @@ struct SessionDetailView: View {
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: store.openApprovals.first?.id)
// The lock card grows and collapses the same glass, so it rides the same spring.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: openLockWait != nil)
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: composerHeight)
@@ -4241,13 +4262,18 @@ private struct JumpToBottomButton: View {
}
}
/// A live "the agent is working" row shown at the foot of the transcript while a
/// turn is in flight, so a sent message visibly has somewhere to land.
/// In-transcript banner for a chat parked in the file-lock queue (LOCKING §4): names the
/// contended files and the chat(s) holding them, escalates to the attention style for a genuine
/// deadlock, and offers the manual escape hatch releasing a holder's locks right where the
/// user is watching the chat "hang". The queued wait resolves itself when the holder's work
/// lands; this exists so that wait is never mistaken for a frozen app.
/// Composer card for a chat parked in the file-lock queue (LOCKING §4): names the contended files
/// and the chat(s) holding them, escalates to the attention style for a genuine deadlock, and
/// offers the manual escape hatch releasing the holders' locks. It rides in the composer's glass
/// shell alongside permission prompts rather than at the transcript's tail, so the explanation sits
/// where the user is about to type into what otherwise looks like a frozen chat and stays put
/// instead of scrolling away. The queued wait resolves itself when the holder's work lands; this
/// exists so that wait is never mistaken for a frozen app.
///
/// Everything here is derived from `wait`, which the store re-derives from the live lock table on
/// every reconcile pass so when the current holder releases and the next chat in the queue is
/// granted the files, the named holder below follows it rather than freezing on whoever was
/// holding them at the moment this chat parked.
struct LockContentionBanner: View {
@Environment(\.appPalette) private var palette
let wait: AppStore.LockWaitInfo
@@ -4282,29 +4308,33 @@ struct LockContentionBanner: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if !wait.holders.isEmpty {
HStack(spacing: 8) {
ForEach(wait.holders) { holder in
Button("Release “\(holder.title)”’s locks") { onReleaseHolder(holder) }
.buttonStyle(.bordered)
.controlSize(.small)
.help("Force-release every file lock that chat holds so this one can proceed. Its unmerged work stays intact; the lock re-arms if it keeps editing.")
}
}
// One button for the whole escape hatch. The holder is named in the sentence
// above and can change under this card as the queue advances so baking a
// name into the label only risked it disagreeing with the text beside it.
Button("Release locks") { wait.holders.forEach(onReleaseHolder) }
.buttonStyle(.bordered)
.controlSize(.small)
.help(
"Force-release every file lock \(holderList) holds so this one can "
+ "proceed. Its unmerged work stays intact; the lock re-arms if it "
+ "keeps editing.")
}
}
.padding(10)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(deadlocked ? palette.attention.opacity(0.08) : Color.primary.opacity(0.04)))
deadlocked ? palette.attention.opacity(0.08) : AppTheme.composerFill,
in: .rect(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: 10)
.strokeBorder(
deadlocked ? palette.attention.opacity(0.35) : Color.primary.opacity(0.08)))
.transition(.opacity)
deadlocked ? palette.attention.opacity(0.35) : AppTheme.composerStroke,
lineWidth: 1))
}
}
/// A live "the agent is working" row shown at the foot of the transcript while a
/// turn is in flight, so a sent message visibly has somewhere to land.
struct WorkingIndicator: View {
let text: String
+59 -2
View File
@@ -6104,8 +6104,65 @@ public final class AppStore: ConflictArbiter {
sessionsWaitingForAccess = Set(raw.waits.map(\.session))
deadlockedSessions = await lockManager.deadlockedSessions()
// Keep the banner truthful: drop wait-info for anything no longer actually queued (its
// arbitrate resolved on another path), so a stale banner can't outlive its wait.
lockWaits = lockWaits.filter { sessionsWaitingForAccess.contains($0.key) }
// arbitrate resolved on another path), and re-derive the rest from the live table.
refreshLockWaits(raw)
}
/// Re-derive every parked chat's `LockWaitInfo` from the live lock table, dropping the ones
/// no longer queued.
///
/// The holders captured when `arbitrate` parked are only true for the head of the queue.
/// Contention is routinely a *line*: A holds the file, B and C queue behind it. When A
/// releases, B is granted and becomes C's blocker but C's park-time snapshot still names A,
/// so its banner keeps pointing at a chat that moved on (and its "Release locks" button aims
/// at locks nobody holds). Recomputing here the pass that already reads `rawState` walks
/// the name down the queue with the lock, and refreshes holder titles that were renamed
/// mid-wait for free.
private func refreshLockWaits(
_ raw: (holds: [CarbonLockManager.RawHold], waits: [CarbonLockManager.RawWait])
) {
guard !lockWaits.isEmpty else { return }
var domainOfWaiter: [SessionID: LockDomain] = [:]
for wait in raw.waits { domainOfWaiter[wait.session] = wait.domain }
var refreshed: [SessionID: LockWaitInfo] = [:]
for (session, wait) in lockWaits {
// No longer in any queue: its arbitrate resolved on another path (granted, cancelled,
// reaped), so the banner has nothing left to describe.
guard let domain = domainOfWaiter[session] else { continue }
let holders = holders(
in: raw.holds, domain: domain, overlapping: wait.files, excluding: session)
// `since` is the wait's own clock, not the holder's a new blocker taking over does
// not restart how long this chat has been queued.
refreshed[session] = holders == wait.holders
? wait
: LockWaitInfo(files: wait.files, holders: holders, since: wait.since)
}
lockWaits = refreshed
}
/// The sessions in `holds` (a `rawState` snapshot) whose held paths overlap `files` in
/// `domain` the same contention test as `contendedHolders`, but off an already-taken
/// snapshot so a reconcile pass needn't hop back into the lock actor per waiter.
private func holders(
in holds: [CarbonLockManager.RawHold], domain: LockDomain, overlapping files: [String],
excluding: SessionID
) -> [LockWaitInfo.Holder] {
var pathsBySession: [SessionID: [String]] = [:]
for hold in holds where hold.domain == domain && hold.session != excluding {
pathsBySession[hold.session, default: []].append(hold.path)
}
return pathsBySession
.filter { _, held in
held.contains { path in files.contains { ConflictDetector.pathsOverlap(path, $0) } }
}
.map { session, _ in
let title = summaries.first { $0.id == session }?.title ?? ""
return LockWaitInfo.Holder(
sessionID: session, title: title.isEmpty ? "another chat" : title)
}
// Stable order, matching `contendedHolders` a dictionary walk alone would let the
// banner reshuffle its holder names between otherwise identical passes.
.sorted { $0.sessionID.rawValue < $1.sessionID.rawValue }
}
/// `ConflictArbiter` (LOCKING §4.4): a git op the in-container interceptor shim observed for
@@ -667,6 +667,51 @@ struct AppStoreTests {
#expect(store.lockWaits[b] == nil)
}
/// Contention is a LINE, not a pair: with two chats queued behind a holder, the first releases
/// to the front of the queue and the chat still waiting is now blocked by that *new* holder.
/// Its banner state must follow the lock down the queue rather than freezing on whoever held
/// the file when it parked (a name that is, by then, simply wrong and a "Release locks"
/// button aimed at locks nobody holds).
@Test func aQueuedChatsBannerNamesTheCurrentHolderNotTheOneItParkedBehind() async throws {
let repo = try await GitTestRepo(controlled: true)
defer { repo.cleanup() }
let store = makeStore(repo: repo)
store.lockReconcileInterval = .milliseconds(20) // the pass that re-derives the holders
let created = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
let project = await enableNvrsion(store, created)
let a = try await store.createSession(in: project, title: "alpha", prompt: "")
let b = try await store.createSession(in: project, title: "bravo", prompt: "")
let c = try await store.createSession(in: project, title: "charlie", prompt: "")
// alpha holds shared.txt; bravo and charlie both queue behind it.
#expect(await store.arbitrate(sessionID: a, task: "edit", files: ["shared.txt"]).resolution == .proceed)
let bravo = Task { await store.arbitrate(sessionID: b, task: "edit", files: ["shared.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(b) }
let charlie = Task { await store.arbitrate(sessionID: c, task: "edit", files: ["shared.txt"]) }
await waitFor { store.sessionsWaitingForAccess.contains(c) }
#expect(store.lockWaits[c]?.holders.map(\.title) == ["alpha"])
let parkedAt = try #require(store.lockWaits[c]?.since)
// alpha releases: bravo (the queue's head) is granted, and charlie keeps waiting now on
// bravo. The banner must say "bravo", and the release hatch must aim at bravo's locks.
await store.forceReleaseLocks(a)
let bravoGranted = await withTimeout { await bravo.value }
#expect(bravoGranted?.resolution == .proceed)
await waitFor { store.lockWaits[c]?.holders.map(\.title) == ["bravo"] }
#expect(store.lockWaits[c]?.holders.map(\.sessionID) == [b])
#expect(store.lockWaits[c]?.files == ["shared.txt"])
// The wait's own clock is charlie's, not the holder's a new blocker taking over does
// not restart how long charlie has been queued.
#expect(store.lockWaits[c]?.since == parkedAt)
// And releasing the *current* holder is what finally grants charlie.
await store.forceReleaseLocks(b)
let charlieGranted = await withTimeout { await charlie.value }
#expect(charlieGranted?.resolution == .proceed)
#expect(store.lockWaits[c] == nil)
}
// MARK: - Host-command concurrency (HOST_EXEC §concurrency) own-worktree exemption
@Test func hostCommandGateExemptsSessionsInTheirOwnWorktree() async throws {