Merge nucleic/zesty-umber-lemur-ciqf into dev
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user