Skip to content
5 changes: 5 additions & 0 deletions Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,11 @@ final class TerminalNotificationStore: ObservableObject {
indexes.latestByTabId[tabId]
}

/// Indexed lookup of one stored notification.
func notification(id: UUID) -> TerminalNotification? {
indexes.notificationByID[id]
}

func notifications(forTabId tabId: UUID, surfaceId: UUID?) -> [TerminalNotification] {
notifications.filter { $0.matches(tabId: tabId, surfaceId: surfaceId) }
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/Workspace+PanelLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ extension Workspace {
if refreshPorts { refreshTrackedAgentPorts() }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id)
}
if let store = AppDelegate.shared?.notificationStore {
pruneOrphanedRestoredAgentNotifications(store: store)
}
return didChange
}

Expand Down
96 changes: 96 additions & 0 deletions Sources/Workspace+RestoredAgentNotifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Foundation

/// Orphaned agent notifications after a session restore.
///
/// Notifications are persisted across relaunch so an unseen agent result is not
/// lost. But an agent that was alive when cmux quit dies with the app without
/// firing its SessionEnd hook, so nothing ever clears its last "Completed in …"
/// notification, and the sidebar keeps showing it as the workspace's latest
/// summary on every subsequent launch. The live-session safety net
/// (`clearStaleAgentPIDs`) cannot catch it either: agent PIDs are not
/// restored, so there is no dead PID to sweep.
///
/// Restore therefore records the notifications that belonged to a local agent
/// pane, and the periodic sweep drops the ones that were read once it is clear
/// the agent did not come back to that pane. Unread ones survive until read.
extension Workspace {
/// Whether a persisted panel was hosting a local agent session that died
/// with the previous app process. Remote terminals are excluded: their agent
/// can outlive the local app and keep owning its notifications.
nonisolated static func restoredPanelHostedLocalAgent(_ panelSnapshot: SessionPanelSnapshot) -> Bool {
guard let terminal = panelSnapshot.terminal else { return false }
guard terminal.isRemoteTerminal != true, terminal.remotePTYSessionID == nil else { return false }
return terminal.resumeBinding != nil || terminal.agent != nil
}

/// Records, per restored panel, the persisted agent notifications of panes
/// that were hosting a local agent, keyed by the panel's new id. Replaces any
/// tracking left from an earlier restore of this workspace.
func trackRestoredAgentNotifications(
from snapshot: SessionWorkspaceSnapshot,
oldToNewPanelIds: [UUID: UUID]
) {
restoredAgentNotificationIdsByPanelId.removeAll(keepingCapacity: false)
guard snapshot.remote == nil else { return }
for panelSnapshot in snapshot.panels where Self.restoredPanelHostedLocalAgent(panelSnapshot) {
guard let newPanelId = oldToNewPanelIds[panelSnapshot.id] else { continue }
// Only agent-produced notifications are the agent's to retire; a
// `cmux notify` banner on the same pane keeps its own lifetime.
// Unknown provenance restores as agent-produced, as the store does.
let ids = Set((panelSnapshot.notifications ?? [])
.filter { $0.isAgentEvent ?? true }
.map(\.id))
guard !ids.isEmpty else { continue }
restoredAgentNotificationIdsByPanelId[newPanelId] = ids
}
#if DEBUG
cmuxDebugLog(
"restoredAgentNotifications.track workspace=\(id.uuidString.prefix(8)) panels=\(snapshot.panels.count) tracked=\(restoredAgentNotificationIdsByPanelId.count)"
)
#endif
}

/// Removes read restored agent notifications on panes the agent never
/// returned to. A pane that registered a new agent PID is handed back to the
/// agent hooks, which own its notifications from then on. A pane whose
/// restored resume is still in flight is left for a later sweep: the agent
/// may not have reported its PID yet.
///
/// Tracked notifications are looked up by id, so a sweep that removes
/// nothing does not scan the store; each removal happens once.
@discardableResult
func pruneOrphanedRestoredAgentNotifications(store: TerminalNotificationStore) -> Bool {
guard !restoredAgentNotificationIdsByPanelId.isEmpty else { return false }
var didRemove = false
for (panelId, trackedIds) in restoredAgentNotificationIdsByPanelId {
if restoredAgentLifecycle.ownsInFlightRestoredCommand(panelId: panelId) { continue }
let hasLiveAgent = !(agentPIDKeysByPanelId[panelId] ?? []).isEmpty
#if DEBUG
cmuxDebugLog(
"restoredAgentNotifications.prune workspace=\(id.uuidString.prefix(8)) panel=\(panelId.uuidString.prefix(8)) exists=\(panels[panelId] != nil ? 1 : 0) liveAgent=\(hasLiveAgent ? 1 : 0) tracked=\(trackedIds.count)"
)
#endif
guard panels[panelId] != nil, !hasLiveAgent else {
restoredAgentNotificationIdsByPanelId.removeValue(forKey: panelId)
continue
}
let remaining = trackedIds.compactMap { store.notification(id: $0) }
.filter { $0.matches(tabId: id, surfaceId: panelId) }
var stillTracked = Set<UUID>()
for notification in remaining {
if notification.isRead {
store.remove(id: notification.id)
didRemove = true
} else {
stillTracked.insert(notification.id)
}
}
if stillTracked.isEmpty {
restoredAgentNotificationIdsByPanelId.removeValue(forKey: panelId)
} else {
restoredAgentNotificationIdsByPanelId[panelId] = stillTracked
}
}
return didRemove
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
7 changes: 7 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ extension Workspace {
AppDelegate.shared?.notificationStore?.clearRestoredUnreadIndicator(forTabId: id)
}
AppDelegate.shared?.notificationStore?.restoreSessionNotifications(restoredNotifications, forTabId: id)
trackRestoredAgentNotifications(from: snapshot, oldToNewPanelIds: oldToNewPanelIds)
// Record the identity remap for the agent journal: events journaled
// against the previous run's runtime workspace/panel UUIDs re-attach
// to the restored panels during replay through these aliases.
Expand Down Expand Up @@ -3317,6 +3318,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
restoredAgentLifecycle.snapshotsByPanelId
}
var surfaceResumeBindingsByPanelId: [UUID: SurfaceResumeBindingSnapshot] = [:]
/// Restored notifications of panes whose agent died with the previous app
/// process; pruned by `pruneOrphanedRestoredAgentNotifications(store:)`.
var restoredAgentNotificationIdsByPanelId: [UUID: Set<UUID>] = [:]
/// Journals agent sessions ended by closing their terminal. Tests point it
/// at a private journal.
var agentSessionCloseJournal = AgentSessionCloseJournal()
Expand Down Expand Up @@ -6727,6 +6731,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
surfaceResumeBindingsByPanelId = surfaceResumeBindingsByPanelId.filter {
validSurfaceIds.contains($0.key)
}
restoredAgentNotificationIdsByPanelId = restoredAgentNotificationIdsByPanelId.filter {
validSurfaceIds.contains($0.key)
}
surfaceResumeRestoreClaimsByPanelId = surfaceResumeRestoreClaimsByPanelId.filter {
validSurfaceIds.contains($0.key)
}
Expand Down
8 changes: 8 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2995,6 +2995,7 @@
F6572052A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */; };
C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */; };
F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */; };
FC2030911C2B51D56174F18C /* RestoredAgentNotificationPruneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */; };
F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */; };
BB8CB1EFD62C8CAE14293195 /* RestoredStartupInputOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */; };
9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; };
Expand Down Expand Up @@ -4236,6 +4237,7 @@
D7AB12048000000000000006 /* Workspace+RemoteTerminalWorkingDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */; };
7738C0037738C0037738C003 /* Workspace+RemoteTmuxControlTopology.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */; };
D77340010000000000000002 /* Workspace+RemoteTmuxTabOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */; };
4141CCBD871156435CF426B6 /* Workspace+RestoredAgentNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */; };
961900000000000000000001 /* Workspace+RestoredPanelTitleBoundary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */; };
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 */; };
Expand Down Expand Up @@ -7361,6 +7363,7 @@
F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentForegroundProcess.swift; sourceTree = "<group>"; };
9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleCoordinator.swift; sourceTree = "<group>"; };
F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLiveness.swift; sourceTree = "<group>"; };
FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredAgentNotificationPruneTests.swift"; sourceTree = "<group>"; };
F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentShellActivityLivenessTests.swift; sourceTree = "<group>"; };
D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredStartupInputOwnershipTests.swift"; sourceTree = "<group>"; };
9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -8590,6 +8593,7 @@
D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTerminalWorkingDirectory.swift"; sourceTree = "<group>"; };
7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxControlTopology.swift"; sourceTree = "<group>"; };
D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxTabOrder.swift"; sourceTree = "<group>"; };
A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredAgentNotifications.swift"; sourceTree = "<group>"; };
961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredPanelTitleBoundary.swift"; sourceTree = "<group>"; };
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>"; };
Expand Down Expand Up @@ -11828,6 +11832,7 @@
DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */,
9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */,
DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */,
A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -13332,6 +13337,7 @@
8E130576821882FB0DDFB0F3 /* NewMachineSheetPresenterTests.swift */,
272AE90B4C33027AB38F708E /* CloudActivationCoordinatorTests.swift */,
F315CAB97E0954B5025E4A2D /* CloudCmdYActivationRoutingTests.swift */,
FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */,
952544C6A0154DD480E89921 /* RemoteTmuxSocketErrorTextTests.swift */,
);
path = cmuxTests;
Expand Down Expand Up @@ -16619,6 +16625,7 @@
D7AB12048000000000000006 /* Workspace+RemoteTerminalWorkingDirectory.swift in Sources */,
7738C0037738C0037738C003 /* Workspace+RemoteTmuxControlTopology.swift in Sources */,
D77340010000000000000002 /* Workspace+RemoteTmuxTabOrder.swift in Sources */,
4141CCBD871156435CF426B6 /* Workspace+RestoredAgentNotifications.swift in Sources */,
961900000000000000000001 /* Workspace+RestoredPanelTitleBoundary.swift in Sources */,
9200A0019200A0019200A001 /* Workspace+RestoredWorkingDirectoryGuard.swift in Sources */,
381B026EFFAB40449AF805E8 /* Workspace+RightSidebarToolDrop.swift in Sources */,
Expand Down Expand Up @@ -18020,6 +18027,7 @@
F54100A0A1B2C3D4E5F60718 /* RestorableAgentSessionStalePIDTests.swift in Sources */,
F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */,
F6572062A1B2C3D4E5F60718 /* RestoreAdmissionRetryTests.swift in Sources */,
FC2030911C2B51D56174F18C /* RestoredAgentNotificationPruneTests.swift in Sources */,
F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */,
BB8CB1EFD62C8CAE14293195 /* RestoredStartupInputOwnershipTests.swift in Sources */,
9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */,
Expand Down
Loading
Loading