From 535089169444d4cde6a0e6832ab192d15a3e4200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=86=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=AB?= Date: Sun, 4 Oct 2026 11:50:06 +0900 Subject: [PATCH 1/6] test: prune read notifications of agents that died with the previous app An agent alive at quit dies without SessionEnd, so its last "Completed in" notification is restored on every launch and shown as the workspace's latest sidebar summary even though no agent is running. The stale-agent sweep must drop it once the pane has no agent again. Co-Authored-By: Claude Opus 5.5 --- cmux.xcodeproj/project.pbxproj | 4 + .../RestoredAgentNotificationPruneTests.swift | 121 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 cmuxTests/RestoredAgentNotificationPruneTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6d606b86fc62..22924e9f392a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2994,6 +2994,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 */; }; @@ -7358,6 +7359,7 @@ F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentForegroundProcess.swift; sourceTree = ""; }; 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleCoordinator.swift; sourceTree = ""; }; F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLiveness.swift; sourceTree = ""; }; + FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredAgentNotificationPruneTests.swift"; sourceTree = ""; }; F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentShellActivityLivenessTests.swift; sourceTree = ""; }; D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredStartupInputOwnershipTests.swift"; sourceTree = ""; }; 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = ""; }; @@ -13327,6 +13329,7 @@ 8E130576821882FB0DDFB0F3 /* NewMachineSheetPresenterTests.swift */, 272AE90B4C33027AB38F708E /* CloudActivationCoordinatorTests.swift */, F315CAB97E0954B5025E4A2D /* CloudCmdYActivationRoutingTests.swift */, + FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -18012,6 +18015,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 */, diff --git a/cmuxTests/RestoredAgentNotificationPruneTests.swift b/cmuxTests/RestoredAgentNotificationPruneTests.swift new file mode 100644 index 000000000000..67fd078853ca --- /dev/null +++ b/cmuxTests/RestoredAgentNotificationPruneTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// An agent that was alive when cmux quit dies with the app without firing +/// SessionEnd, so its last notification used to be restored on every launch and +/// shown as the workspace's latest summary forever. The periodic stale-agent +/// sweep (`clearStaleAgentPIDs`) is what must notice it. +@MainActor +@Suite(.serialized) +struct RestoredAgentNotificationPruneTests { + @Test func readNotificationOfAgentThatDidNotReturnIsPrunedAfterRestore() throws { + try withIsolatedNotificationStore { store in + let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: true, notifications: [ + (body: "Read turn summary", isRead: true), + (body: "Unread turn summary", isRead: false), + ]) + #expect(store.notifications(forTabId: restored.id, surfaceId: panelId).count == 2) + + restored.clearStaleAgentPIDs(refreshPorts: false) + + let remaining = store.notifications(forTabId: restored.id, surfaceId: panelId) + #expect(remaining.map(\.body) == ["Unread turn summary"]) + } + } + + @Test func readNotificationIsKeptWhenAgentReturnsToThePane() throws { + try withIsolatedNotificationStore { store in + let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: true, notifications: [ + (body: "Read turn summary", isRead: true), + ]) + restored.recordAgentPID(key: "claude_code", pid: getpid(), panelId: panelId, refreshPorts: false) + + restored.clearStaleAgentPIDs(refreshPorts: false) + + #expect(store.notifications(forTabId: restored.id, surfaceId: panelId).map(\.body) == ["Read turn summary"]) + } + } + + @Test func readNotificationOnPaneWithoutAgentIsLeftAlone() throws { + try withIsolatedNotificationStore { store in + let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: false, notifications: [ + (body: "Build finished", isRead: true), + ]) + + restored.clearStaleAgentPIDs(refreshPorts: false) + + #expect(store.notifications(forTabId: restored.id, surfaceId: panelId).map(\.body) == ["Build finished"]) + } + } + + private func withIsolatedNotificationStore(_ body: (TerminalNotificationStore) throws -> Void) throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let originalNotificationStore = appDelegate.notificationStore + appDelegate.notificationStore = store + store.replaceNotificationsForTesting([]) + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + try body(store) + } + + /// Persists a workspace whose focused pane holds `notifications`, then + /// restores it into a fresh workspace with agent auto-resume disabled, the + /// same state a relaunch leaves behind when the agent does not come back. + private func restoreWorkspace( + store: TerminalNotificationStore, + hostsAgent: Bool, + notifications: [(body: String, isRead: Bool)] + ) throws -> (Workspace, UUID) { + let suiteName = "cmux-restored-agent-notifications-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(false, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) + + let workspace = Workspace(agentSessionAutoResumeDefaults: defaults) + let panelId = try #require(workspace.focusedPanelId) + if hostsAgent { + #expect(workspace.setSurfaceResumeBinding( + SurfaceResumeBindingSnapshot( + name: "Claude Code", + kind: "claude", + command: "claude --resume session-restored-agent-notification", + cwd: "/tmp", + checkpointId: "session-restored-agent-notification", + source: "agent-hook", + autoResume: false, + updatedAt: 10 + ), + panelId: panelId + )) + } + store.replaceNotificationsForTesting(notifications.enumerated().map { index, notification in + TerminalNotification( + id: UUID(), + tabId: workspace.id, + surfaceId: panelId, + panelId: panelId, + title: "Claude Code", + subtitle: "Completed", + body: notification.body, + createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), + isRead: notification.isRead + ) + }) + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + store.replaceNotificationsForTesting([]) + let restored = Workspace(agentSessionAutoResumeDefaults: defaults) + restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try #require(restored.focusedPanelId) + return (restored, restoredPanelId) + } +} From 5162bfbdc6ee2180b8835c86a905a2ca274745b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=86=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=AB?= Date: Sun, 4 Oct 2026 11:50:06 +0900 Subject: [PATCH 2/6] fix: drop restored agent notifications once the agent does not return Notifications persist across relaunch so an unseen agent result is not lost, but an agent that was alive when cmux quit dies without SessionEnd. Nothing clears its notification afterwards: agent PIDs are not restored, so the 30s stale-PID sweep has no dead PID to catch. Restore now records the notifications of local panes that hosted an agent (resume binding or restorable agent snapshot). The stale-agent sweep removes the read ones when the pane has no agent PID again; unread ones survive until read, and a pane the agent resumes into is handed back to its hooks. Remote terminals are skipped since their agent can outlive the app. Co-Authored-By: Claude Opus 5.5 --- Sources/Workspace+PanelLifecycle.swift | 3 + ...Workspace+RestoredAgentNotifications.swift | 82 +++++++++++++++++++ Sources/Workspace.swift | 7 ++ cmux.xcodeproj/project.pbxproj | 4 + 4 files changed, 96 insertions(+) create mode 100644 Sources/Workspace+RestoredAgentNotifications.swift diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index ceb2ae8fb6d0..45b340d1d031 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -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 } diff --git a/Sources/Workspace+RestoredAgentNotifications.swift b/Sources/Workspace+RestoredAgentNotifications.swift new file mode 100644 index 000000000000..427903e22c8b --- /dev/null +++ b/Sources/Workspace+RestoredAgentNotifications.swift @@ -0,0 +1,82 @@ +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. + 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 + } + + 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 } + let ids = Set((panelSnapshot.notifications ?? []).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. + @discardableResult + func pruneOrphanedRestoredAgentNotifications(store: TerminalNotificationStore) -> Bool { + guard !restoredAgentNotificationIdsByPanelId.isEmpty else { return false } + var didRemove = false + for (panelId, trackedIds) in restoredAgentNotificationIdsByPanelId { + 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 = store.notifications(forTabId: id, surfaceId: panelId) + .filter { trackedIds.contains($0.id) } + var stillTracked = Set() + 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 + } +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f5603247a5d9..d0fc21b0be31 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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. @@ -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] = [:] /// Journals agent sessions ended by closing their terminal. Tests point it /// at a private journal. var agentSessionCloseJournal = AgentSessionCloseJournal() @@ -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) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 22924e9f392a..6dabdd2ec0f0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -4235,6 +4235,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 */; }; @@ -8588,6 +8589,7 @@ D7AB12048000000000000007 /* Workspace+RemoteTerminalWorkingDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTerminalWorkingDirectory.swift"; sourceTree = ""; }; 7738D0037738D0037738D003 /* Workspace+RemoteTmuxControlTopology.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxControlTopology.swift"; sourceTree = ""; }; D77340010000000000000001 /* Workspace+RemoteTmuxTabOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RemoteTmuxTabOrder.swift"; sourceTree = ""; }; + A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredAgentNotifications.swift"; sourceTree = ""; }; 961900000000000000000002 /* Workspace+RestoredPanelTitleBoundary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+RestoredPanelTitleBoundary.swift"; sourceTree = ""; }; 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 = ""; }; @@ -11825,6 +11827,7 @@ DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */, 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */, DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */, + A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */, ); path = Sources; sourceTree = ""; @@ -16615,6 +16618,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 */, From 3eb9deaa7f3cfd19403886912fb485639440970c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=86=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=AB?= Date: Sun, 4 Oct 2026 11:50:06 +0900 Subject: [PATCH 3/6] test: keep restored notifications while the resume is in flight Covers a read notification posted after restore (never tracked) and a pane whose restored resume has not reported an agent PID yet. Co-Authored-By: Claude Opus 5.5 --- .../RestoredAgentNotificationPruneTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cmuxTests/RestoredAgentNotificationPruneTests.swift b/cmuxTests/RestoredAgentNotificationPruneTests.swift index 67fd078853ca..a517dd20d2ab 100644 --- a/cmuxTests/RestoredAgentNotificationPruneTests.swift +++ b/cmuxTests/RestoredAgentNotificationPruneTests.swift @@ -29,6 +29,48 @@ struct RestoredAgentNotificationPruneTests { } } + @Test func readNotificationPostedAfterRestoreIsNotPruned() throws { + try withIsolatedNotificationStore { store in + let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: true, notifications: [ + (body: "Restored read summary", isRead: true), + ]) + store.replaceNotificationsForTesting(store.notifications + [ + TerminalNotification( + id: UUID(), + tabId: restored.id, + surfaceId: panelId, + panelId: panelId, + title: "Claude Code", + subtitle: "Completed", + body: "Posted after restore", + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + isRead: true + ), + ]) + + restored.clearStaleAgentPIDs(refreshPorts: false) + + #expect(store.notifications(forTabId: restored.id, surfaceId: panelId).map(\.body) == ["Posted after restore"]) + } + } + + @Test func readNotificationIsKeptWhileRestoredResumeIsInFlight() throws { + try withIsolatedNotificationStore { store in + let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: true, notifications: [ + (body: "Read turn summary", isRead: true), + ]) + restored.restoredAgentLifecycle.setResumeState(.awaitingAutoResumeCommand, panelId: panelId) + + restored.clearStaleAgentPIDs(refreshPorts: false) + #expect(store.notifications(forTabId: restored.id, surfaceId: panelId).map(\.body) == ["Read turn summary"]) + + // The resume ended without the agent reporting a PID. + restored.restoredAgentLifecycle.setResumeState(nil, panelId: panelId) + restored.clearStaleAgentPIDs(refreshPorts: false) + #expect(store.notifications(forTabId: restored.id, surfaceId: panelId).isEmpty) + } + } + @Test func readNotificationIsKeptWhenAgentReturnsToThePane() throws { try withIsolatedNotificationStore { store in let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: true, notifications: [ From fd78af5b74436ced575f038e81bece50aface62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=86=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=AB?= Date: Sun, 4 Oct 2026 11:50:06 +0900 Subject: [PATCH 4/6] fix: defer restored notification prune while the resume is in flight The 30-second sweep can run before an auto-resumed agent reports its PID. Skip panes whose restored command is still in flight, using the coordinator's existing ownsInFlightRestoredCommand contract. Also look up tracked notifications by id instead of scanning the store per panel, and mark the value-only snapshot helper nonisolated. Co-Authored-By: Claude Opus 5.5 --- Sources/TerminalNotificationStore.swift | 5 +++++ .../Workspace+RestoredAgentNotifications.swift | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index c9c5af5fa17e..807ef126e86d 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -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) } } diff --git a/Sources/Workspace+RestoredAgentNotifications.swift b/Sources/Workspace+RestoredAgentNotifications.swift index 427903e22c8b..b1467dbb5f64 100644 --- a/Sources/Workspace+RestoredAgentNotifications.swift +++ b/Sources/Workspace+RestoredAgentNotifications.swift @@ -17,12 +17,15 @@ 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. - static func restoredPanelHostedLocalAgent(_ panelSnapshot: SessionPanelSnapshot) -> Bool { + 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 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] @@ -44,12 +47,18 @@ extension Workspace { /// 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. + /// 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( @@ -60,8 +69,8 @@ extension Workspace { restoredAgentNotificationIdsByPanelId.removeValue(forKey: panelId) continue } - let remaining = store.notifications(forTabId: id, surfaceId: panelId) - .filter { trackedIds.contains($0.id) } + let remaining = trackedIds.compactMap { store.notification(id: $0) } + .filter { $0.matches(tabId: id, surfaceId: panelId) } var stillTracked = Set() for notification in remaining { if notification.isRead { From f144652e0938b4d11eb0783722a354755b26690e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=86=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=AB?= Date: Sun, 4 Oct 2026 11:50:06 +0900 Subject: [PATCH 5/6] test: keep read non-agent notifications on a restored agent pane The restore tracks every notification persisted on a pane that hosted an agent, so a read `cmux notify` banner on that pane is pruned with the agent's result. Co-Authored-By: Claude Opus 5.5 --- .../RestoredAgentNotificationPruneTests.swift | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/cmuxTests/RestoredAgentNotificationPruneTests.swift b/cmuxTests/RestoredAgentNotificationPruneTests.swift index a517dd20d2ab..577f41f1215f 100644 --- a/cmuxTests/RestoredAgentNotificationPruneTests.swift +++ b/cmuxTests/RestoredAgentNotificationPruneTests.swift @@ -29,6 +29,22 @@ struct RestoredAgentNotificationPruneTests { } } + @Test func readNonAgentNotificationOnRestoredAgentPaneIsNotPruned() throws { + try withIsolatedNotificationStore { store in + let (restored, panelId) = try restoreWorkspace( + store: store, + hostsAgent: true, + notifications: [(body: "Read turn summary", isRead: true)], + nonAgentNotifications: [(body: "Build finished", isRead: true)] + ) + + restored.clearStaleAgentPIDs(refreshPorts: false) + + let remaining = store.notifications(forTabId: restored.id, surfaceId: panelId) + #expect(remaining.map(\.body) == ["Build finished"]) + } + } + @Test func readNotificationPostedAfterRestoreIsNotPruned() throws { try withIsolatedNotificationStore { store in let (restored, panelId) = try restoreWorkspace(store: store, hostsAgent: true, notifications: [ @@ -115,7 +131,8 @@ struct RestoredAgentNotificationPruneTests { private func restoreWorkspace( store: TerminalNotificationStore, hostsAgent: Bool, - notifications: [(body: String, isRead: Bool)] + notifications: [(body: String, isRead: Bool)], + nonAgentNotifications: [(body: String, isRead: Bool)] = [] ) throws -> (Workspace, UUID) { let suiteName = "cmux-restored-agent-notifications-\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) @@ -139,17 +156,21 @@ struct RestoredAgentNotificationPruneTests { panelId: panelId )) } - store.replaceNotificationsForTesting(notifications.enumerated().map { index, notification in - TerminalNotification( + let tagged = notifications.map { ($0.body, $0.isRead, true) } + + nonAgentNotifications.map { ($0.body, $0.isRead, false) } + store.replaceNotificationsForTesting(tagged.enumerated().map { index, notification in + let (body, isRead, isAgentEvent) = notification + return TerminalNotification( id: UUID(), tabId: workspace.id, surfaceId: panelId, panelId: panelId, title: "Claude Code", subtitle: "Completed", - body: notification.body, + body: body, createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), - isRead: notification.isRead + isRead: isRead, + isAgentEvent: isAgentEvent ) }) From 779a72ea20a5aab1545176bfb91269ed511081f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=86=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=8B=E1=85=A7=E1=86=AB?= Date: Sun, 4 Oct 2026 11:50:07 +0900 Subject: [PATCH 6/6] fix: track only agent-produced notifications on restored agent panes Use the persisted `isAgentEvent` provenance instead of panel ownership so a `cmux notify` banner on a pane that hosted an agent is not retired with the agent's result. Unknown provenance restores as agent-produced, matching TerminalNotificationStore. Co-Authored-By: Claude Opus 5.5 --- Sources/Workspace+RestoredAgentNotifications.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/Workspace+RestoredAgentNotifications.swift b/Sources/Workspace+RestoredAgentNotifications.swift index b1467dbb5f64..99a10141a11a 100644 --- a/Sources/Workspace+RestoredAgentNotifications.swift +++ b/Sources/Workspace+RestoredAgentNotifications.swift @@ -23,8 +23,8 @@ extension Workspace { return terminal.resumeBinding != nil || terminal.agent != nil } - /// Records, per restored panel, the persisted notifications of panes that - /// were hosting a local agent, keyed by the panel's new id. Replaces any + /// 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, @@ -34,7 +34,12 @@ extension Workspace { guard snapshot.remote == nil else { return } for panelSnapshot in snapshot.panels where Self.restoredPanelHostedLocalAgent(panelSnapshot) { guard let newPanelId = oldToNewPanelIds[panelSnapshot.id] else { continue } - let ids = Set((panelSnapshot.notifications ?? []).map(\.id)) + // 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 }