diff --git a/Sources/Workspace+SharedLiveAgentIndex.swift b/Sources/Workspace+SharedLiveAgentIndex.swift new file mode 100644 index 000000000000..1a85f3214fb3 --- /dev/null +++ b/Sources/Workspace+SharedLiveAgentIndex.swift @@ -0,0 +1,37 @@ +import Foundation + +extension Workspace { + /// Reconciles previously completed restored agents against the fresh index. + /// Only their lifecycle state changes; this never publishes the workspace. + func handleSharedLiveAgentIndexChange(userInfo: [AnyHashable: Any]?) { + guard !isRetiredFromOwningTabManager else { return } + if let index = SharedLiveAgentIndex.shared.index { + let completedPanelIds: [UUID] + if let panelIdsByWorkspaceId = userInfo?["panelIdsByWorkspaceId"] as? [UUID: Set] { + guard let changedPanelIds = panelIdsByWorkspaceId[self.id] else { + return + } + completedPanelIds = changedPanelIds.filter { panelId in + self.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit + } + } else if let changedWorkspaceId = userInfo?["workspaceId"] as? UUID, + changedWorkspaceId != self.id { + return + } else if let changedPanelId = userInfo?["panelId"] as? UUID { + completedPanelIds = self.restoredAgentResumeStatesByPanelId[changedPanelId] == .completedAgentExit + ? [changedPanelId] + : [] + } else { + completedPanelIds = self.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in + state == .completedAgentExit ? panelId : nil + } + } + for panelId in completedPanelIds { + guard let observation = index.entry(workspaceId: self.id, panelId: panelId) else { + continue + } + self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) + } + } + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 610082f193bb..7cdd887c9d20 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4624,44 +4624,20 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() scheduleExtensionSidebarProjectRootRefresh(for: currentDirectory) - // Forward shared agent-index refreshes so the bonsplit tab-bar re-evaluates - // Fork Conversation availability when a background refresh lands. + // The Fork Conversation menu reads availability from its provider when + // it opens, so a refresh must not publish this workspace: a full reload + // posts without userInfo at most every 5 s while agent hooks write the + // hook store, and each publish re-rendered every pane (issue 22703). + // SharedLiveAgentIndex is MainActor, so it posts on the main thread and + // the change is handled before post returns. sharedLiveAgentIndexObserver = NotificationCenter.default.addObserver( forName: .sharedLiveAgentIndexDidChange, object: SharedLiveAgentIndex.shared, - queue: nil + queue: .main ) { [weak self] notification in - Task { @MainActor [weak self] in - guard let self, !self.isRetiredFromOwningTabManager else { return } - if let index = SharedLiveAgentIndex.shared.index { - let completedPanelIds: [UUID] - if let panelIdsByWorkspaceId = notification.userInfo?["panelIdsByWorkspaceId"] as? [UUID: Set] { - guard let changedPanelIds = panelIdsByWorkspaceId[self.id] else { - return - } - completedPanelIds = changedPanelIds.filter { panelId in - self.restoredAgentResumeStatesByPanelId[panelId] == .completedAgentExit - } - } else if let changedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, - changedWorkspaceId != self.id { - return - } else if let changedPanelId = notification.userInfo?["panelId"] as? UUID { - completedPanelIds = self.restoredAgentResumeStatesByPanelId[changedPanelId] == .completedAgentExit - ? [changedPanelId] - : [] - } else { - completedPanelIds = self.restoredAgentResumeStatesByPanelId.compactMap { panelId, state in - state == .completedAgentExit ? panelId : nil - } - } - for panelId in completedPanelIds { - guard let observation = index.entry(workspaceId: self.id, panelId: panelId) else { - continue - } - self.reconcileCompletedRestoredAgent(panelId: panelId, observation: observation) - } - } - self.objectWillChange.send() + let userInfo = notification.userInfo + MainActor.assumeIsolated { + self?.handleSharedLiveAgentIndexChange(userInfo: userInfo) } } featureFlagsObserver = NotificationCenter.default.addObserver( @@ -4690,7 +4666,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } } - private var sharedLiveAgentIndexObserver: NSObjectProtocol? + private(set) var sharedLiveAgentIndexObserver: NSObjectProtocol? deinit { for registrations in pendingTerminalInputObserversByPanelId.values { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e183faaebb79..c9dc3058773e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -4569,6 +4569,7 @@ 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */; }; 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */; }; C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */; }; + A45B02D25FC8C5AF22850E39 /* Workspace+SharedLiveAgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */; }; C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7268002000000000000002 /* Workspace+SidebarDirectories.swift */; }; C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */; }; C51A710000000000000000C1 /* Workspace+SimulatorPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */; }; @@ -4617,6 +4618,7 @@ 5732A0045732A0045732A004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */; }; DFAAE6D5B2C449C4BC3AC238 /* WorkspaceContentView+PanelVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043F767DF75A4170B35210E7 /* WorkspaceContentView+PanelVisibility.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; + 95FDF35378E51CD79C9D008F /* WorkspaceContentViewVisibilityTests+AgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F7962E5DDA94319A98612BDC /* WorkspaceCreateIdempotencyIsolationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D745F48BE9C06D2ED4C2E3DC /* WorkspaceCreateIdempotencyIsolationTests.swift */; }; D1A700000000000000000008 /* WorkspaceCreateIdempotencyRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */; }; @@ -9230,6 +9232,7 @@ 9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredWorkingDirectoryGuard.swift"; sourceTree = ""; }; 52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RightSidebarToolDrop.swift"; sourceTree = ""; }; C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SessionRestoreIdentity.swift"; sourceTree = ""; }; + 9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SharedLiveAgentIndex.swift"; sourceTree = ""; }; C7268002000000000000002 /* Workspace+SidebarDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarDirectories.swift"; sourceTree = ""; }; C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarStatusVisibility.swift"; sourceTree = ""; }; C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SimulatorPane.swift"; sourceTree = ""; }; @@ -9278,6 +9281,7 @@ 5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentMinimalModeSafeAreaModifier.swift; sourceTree = ""; }; 043F767DF75A4170B35210E7 /* WorkspaceContentView+PanelVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceContentView+PanelVisibility.swift"; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; + A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceContentViewVisibilityTests+AgentIndex.swift"; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; D745F48BE9C06D2ED4C2E3DC /* WorkspaceCreateIdempotencyIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceCreateIdempotencyIsolationTests.swift"; sourceTree = ""; }; D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateIdempotencyRecoveryTests.swift; sourceTree = ""; }; @@ -12666,6 +12670,7 @@ F1C6800D38EC5F238A2994FC /* DockSplitStore+ChiefPane.swift */, 7947118FF892144AE54C82D2 /* ChiefPaneDragPayload.swift */, 0D10F73113F55349CE0E475D /* ChiefHomeConversationRows.swift */, + 9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */, C2452EDA6ED2355F908EE912 /* AppDelegate+KiroAgentMessages.swift */, D4D9700C7689786CCDB82CAE /* TerminalController+AgentMessageClaim.swift */, ); @@ -14312,6 +14317,7 @@ FBFA082D961AFAB3FFBFF6CC /* RawOSCMachinePayloadTests.swift */, E5E915CF2237052FDE02EE05 /* WorkspaceTitleRecoveryTests.swift */, AD2084D340B702301EBB1356 /* SurfaceSocketCommandTests+NewDisplay.swift */, + A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */, C978CC3C2D3FCCE66696C72C /* CLIKiroGlobalHooksTests.swift */, B88325A2907EB7D69739349B /* ClaudeBackgroundWorkDrainTests.swift */, ); @@ -17812,6 +17818,7 @@ 9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */, 381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */, C548600A000000000000001 /* Workspace+SessionRestoreIdentity.swift in Sources */, + A45B02D25FC8C5AF22850E39 /* Workspace+SharedLiveAgentIndex.swift in Sources */, C7268002000000000000001 /* Workspace+SidebarDirectories.swift in Sources */, C3744E100000000000000001 /* Workspace+SidebarStatusVisibility.swift in Sources */, C51A710000000000000000C1 /* Workspace+SimulatorPane.swift in Sources */, @@ -19637,6 +19644,7 @@ C12524000000000000000001 /* WorkspaceClosePanelFallbackTests.swift in Sources */, D7AB3605C10DEF0000000001 /* WorkspaceCloseTabsContextMenuTests.swift in Sources */, 970900030000000000000001 /* WorkspaceColorMenuTests.swift in Sources */, + 95FDF35378E51CD79C9D008F /* WorkspaceContentViewVisibilityTests+AgentIndex.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F7962E5DDA94319A98612BDC /* WorkspaceCreateIdempotencyIsolationTests.swift in Sources */, D1A700000000000000000008 /* WorkspaceCreateIdempotencyRecoveryTests.swift in Sources */, diff --git a/cmuxTests/ClosedMainWindowRoutingTests.swift b/cmuxTests/ClosedMainWindowRoutingTests.swift index 900e68d8ea8a..139e21ec6144 100644 --- a/cmuxTests/ClosedMainWindowRoutingTests.swift +++ b/cmuxTests/ClosedMainWindowRoutingTests.swift @@ -2991,8 +2991,8 @@ struct MainWindowKeyObservationOwnershipTests { @MainActor @Suite("Workspace process-wide observer retirement", .serialized) struct WorkspaceProcessWideObserverRetirementTests { - @Test("Retirement rejects queued and future shared agent index publications") - func retirementRejectsQueuedAndFutureSharedAgentIndexPublications() async throws { + @Test("Retirement removes the shared agent index observer") + func retirementRemovesSharedAgentIndexObserver() async throws { let manager = TabManager() let workspace = try #require(manager.selectedWorkspace) defer { @@ -3004,41 +3004,11 @@ struct WorkspaceProcessWideObserverRetirementTests { } await drainMainActorQueue() - var publicationCount = 0 - let publication = workspace.objectWillChange.sink { - publicationCount += 1 - } - defer { publication.cancel() } + #expect(workspace.sharedLiveAgentIndexObserver != nil) - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared, - userInfo: ["workspaceId": workspace.id] - ) - await drainMainActorQueue() - #expect(publicationCount > 0) - - // Queue one final matching publication, then retire before its - // MainActor task can mutate the workspace. - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared, - userInfo: ["workspaceId": workspace.id] - ) workspace.retireFromOwningTabManager() - let countAfterRetirement = publicationCount - await drainMainActorQueue() - #expect(publicationCount == countAfterRetirement) - - for _ in 0..<3 { - NotificationCenter.default.post( - name: .sharedLiveAgentIndexDidChange, - object: SharedLiveAgentIndex.shared, - userInfo: ["workspaceId": workspace.id] - ) - } - await drainMainActorQueue() - #expect(publicationCount == countAfterRetirement) + // A shared agent-index change no longer reaches a retired workspace. + #expect(workspace.sharedLiveAgentIndexObserver == nil) } @Test("Retirement rejects queued and future feature flag publications") diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift b/cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift new file mode 100644 index 000000000000..7f1beedaa55e --- /dev/null +++ b/cmuxTests/WorkspaceContentViewVisibilityTests+AgentIndex.swift @@ -0,0 +1,138 @@ +import Testing +import AppKit +import Combine +import CmuxNotifications +import CmuxUpdater +import CoreGraphics +import Observation +import SwiftUI +@testable import Bonsplit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension WorkspaceContentViewVisibilityTests { + /// `SharedLiveAgentIndex` reloads at most every 5 s while agent hooks write + /// the hook store, and each full reload posts `.sharedLiveAgentIndexDidChange` + /// without `userInfo`. Every workspace used to answer with + /// `objectWillChange`, which re-ran `WorkspaceContentView` and every pane's + /// `updateNSView` and portal reconciliation while the user was idle: the + /// burst that precedes the 0.65.x idle hangs (issue 22703). + @Test(.timeLimit(.minutes(1))) + @MainActor + func testSharedAgentIndexReloadDoesNotReevaluatePaneContent() async throws { + _ = NSApplication.shared + + let suiteName = "WorkspaceContentViewAgentIndexReloadTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + + // Loading-card workspaces in a controlled project root, as in the tests + // above, so no shell, git or project-root discovery publishes during + // the measured window. Await each root rather than counting idle turns. + let projectRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(suiteName, isDirectory: true) + .resolvingSymlinksInPath() + try FileManager.default.createDirectory( + at: projectRoot.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: projectRoot) } + let tabManager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: false) + defer { tabManager.finalizeAllWorkspacesForWindowClose() } + for index in 0..<3 { + tabManager.addWorkspace( + workingDirectory: projectRoot.path, + initialSurface: .cloudVMLoading, + select: index == 0, + autoWelcomeIfNeeded: false + ) + } + for workspace in tabManager.tabs { + for await root in workspace.$extensionSidebarProjectRootPath.values { + if root == projectRoot.path { break } + } + try #require(workspace.extensionSidebarProjectRootPath == projectRoot.path) + } + let counts = MinimalModeBodyProbeCounts() + let root = ContentView(updateViewModel: UpdateStateModel(), windowId: UUID()) + .environmentObject(tabManager) + .environmentObject(TerminalNotificationStore.shared) + .environmentObject(SidebarState()) + .environmentObject(SidebarSelectionState()) + .environmentObject(FileExplorerState(defaults: defaults)) + .environmentObject(CmuxConfigStore()) + .environment( + \.minimalModeInvalidationProbe, + MinimalModeInvalidationProbe( + shouldTraceBodyChanges: { counts.isMeasuringInvalidations }, + contentViewBody: { counts.contentViewBody += 1 }, + workspaceContentBody: { counts.workspaceContentBody += 1 }, + verticalTabsSidebarBody: { counts.verticalTabsSidebarBody += 1 } + ) + ) + .defaultAppStorage(defaults) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 640), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = MainWindowHostingView(rootView: root) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + defer { + window.contentView = nil + window.close() + } + + await Self.drainMainRunLoop(for: window) + #expect(counts.contentViewBody > 0) + #expect(counts.workspaceContentBody > 0) + let settled = await Self.waitForQuietChromeBodies(counts: counts, window: window) + try #require(settled, "The window must stop re-evaluating chrome bodies while idle") + + counts.reset() + counts.isMeasuringInvalidations = true + defer { counts.isMeasuringInvalidations = false } + var workspacePublishes = 0 + // Observe the real classic Workspace publisher: the regression is an + // unconditional send without a property mutation. Observation tracking + // on a mock cannot detect it, including for workspaces not on screen. + let publishes = tabManager.tabs.map { workspace in + workspace.objectWillChange.sink { _ in workspacePublishes += 1 } + } + defer { publishes.forEach { $0.cancel() } } + // Post the notification emitted by SharedLiveAgentIndex.startReload. Each + // workspace's observer handles it before post returns; the drain lets + // any SwiftUI update it scheduled run before the counts are read. + try #require(tabManager.tabs.allSatisfy { $0.sharedLiveAgentIndexObserver != nil }) + NotificationCenter.default.post( + name: .sharedLiveAgentIndexDidChange, + object: SharedLiveAgentIndex.shared + ) + await Self.drainMainRunLoop(for: window) + counts.isMeasuringInvalidations = false + + #expect( + workspacePublishes == 0, + "An agent-index reload must not publish any workspace (\(workspacePublishes) publishes)." + ) + #expect( + counts.workspaceContentBody == 0, + "An agent-index reload must not rebuild pane content (\(counts.workspaceContentBody) evaluations)." + ) + #expect( + counts.contentViewBody == 0, + "An agent-index reload must not re-evaluate ContentView (\(counts.contentViewBody) evaluations)." + ) + } +} diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift index be7959d72c34..5dc76f6e35cd 100644 --- a/cmuxTests/WorkspaceContentViewVisibilityTests.swift +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -16,7 +16,7 @@ import SwiftUI @Suite(.serialized) final class WorkspaceContentViewVisibilityTests { - private final class MinimalModeBodyProbeCounts { + final class MinimalModeBodyProbeCounts { var isMeasuringInvalidations = false var contentViewBody = 0 var workspaceContentBody = 0 @@ -642,7 +642,7 @@ final class WorkspaceContentViewVisibilityTests { /// Drains until three consecutive drains re-evaluate no chrome body, so a /// measurement only counts what the change under test invalidates. @MainActor - private static func waitForQuietChromeBodies( + static func waitForQuietChromeBodies( counts: MinimalModeBodyProbeCounts, window: NSWindow ) async -> Bool { @@ -659,7 +659,7 @@ final class WorkspaceContentViewVisibilityTests { } @MainActor - private static func drainMainRunLoop(for window: NSWindow, iterations: Int = 20) async { + static func drainMainRunLoop(for window: NSWindow, iterations: Int = 20) async { for _ in 0..