Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Sources/Workspace+SharedLiveAgentIndex.swift
Original file line number Diff line number Diff line change
@@ -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<UUID>] {
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)
}
}
}
}
46 changes: 11 additions & 35 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID>] {
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(
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -9230,6 +9232,7 @@
9200A0019200A0019200A002 /* Workspace+RestoredWorkingDirectoryGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredWorkingDirectoryGuard.swift"; sourceTree = "<group>"; };
52ACFA4197424088998A76C0 /* Workspace+RightSidebarToolDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RightSidebarToolDrop.swift"; sourceTree = "<group>"; };
C548600A000000000000002 /* Workspace+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SessionRestoreIdentity.swift"; sourceTree = "<group>"; };
9EEA4A95E38F9F819B101793 /* Workspace+SharedLiveAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SharedLiveAgentIndex.swift"; sourceTree = "<group>"; };
C7268002000000000000002 /* Workspace+SidebarDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarDirectories.swift"; sourceTree = "<group>"; };
C3744E100000000000000002 /* Workspace+SidebarStatusVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SidebarStatusVisibility.swift"; sourceTree = "<group>"; };
C51A710000000000000000C2 /* Workspace+SimulatorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SimulatorPane.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -9278,6 +9281,7 @@
5732B0045732B0045732B004 /* WorkspaceContentMinimalModeSafeAreaModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentMinimalModeSafeAreaModifier.swift; sourceTree = "<group>"; };
043F767DF75A4170B35210E7 /* WorkspaceContentView+PanelVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceContentView+PanelVisibility.swift"; sourceTree = "<group>"; };
A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = "<group>"; };
A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceContentViewVisibilityTests+AgentIndex.swift"; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
D745F48BE9C06D2ED4C2E3DC /* WorkspaceCreateIdempotencyIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceCreateIdempotencyIsolationTests.swift"; sourceTree = "<group>"; };
D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCreateIdempotencyRecoveryTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
);
Expand Down Expand Up @@ -14312,6 +14317,7 @@
FBFA082D961AFAB3FFBFF6CC /* RawOSCMachinePayloadTests.swift */,
E5E915CF2237052FDE02EE05 /* WorkspaceTitleRecoveryTests.swift */,
AD2084D340B702301EBB1356 /* SurfaceSocketCommandTests+NewDisplay.swift */,
A6FA6CA3BD8C6FB44634D416 /* WorkspaceContentViewVisibilityTests+AgentIndex.swift */,
C978CC3C2D3FCCE66696C72C /* CLIKiroGlobalHooksTests.swift */,
B88325A2907EB7D69739349B /* ClaudeBackgroundWorkDrainTests.swift */,
);
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
40 changes: 5 additions & 35 deletions cmuxTests/ClosedMainWindowRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
Loading
Loading