Repository navigation
cmux NEXT: delete an incognito workspace's agent chats when it closes #30828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9761c3f
f2f162c
572f459
05739a8
eaeea6d
01b84c2
0ad482a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| public import Foundation | ||
|
|
||
| extension AcpmuxEnvironment { | ||
| /// Stops and deletes every chat this daemon recorded for cmux workspace `key` (its sub-sessions, | ||
| /// forks, and agents started from its terminals included), with the transcripts those chats wrote | ||
| /// (`_acpmux/workspace_purge`, cmux-tui acpmux `server/chats/workspace_purge.rs`). acpmux decides which | ||
| /// chats those are; an incognito workspace's close sends this. It never starts a daemon. | ||
| public nonisolated func purgeWorkspace(key: String, deadline: Duration = .seconds(5)) async throws { | ||
| _ = try await AcpmuxStatusClient.call(socketPath: socketPath, method: "_acpmux/workspace_purge", | ||
| params: ["workspaceId": key], deadline: deadline) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -205,7 +205,13 @@ final class QuitCoordinator { | |
| .save(services.cache.terminals.mapValues(\.session)) | ||
| }, | ||
| browserRecords: { await services.cache.browserTabs.flushRecords() }, | ||
| windows: { await services.windows.prepareForTermination() }, | ||
| windows: { | ||
| // Incognito windows' chats are purged before their spaces go (IncognitoChats), and | ||
| // the step waits for them: every quit, Keep included, waits for a step past its limit. | ||
| IncognitoChats.closingForQuit(services) | ||
| await services.windows.prepareForTermination() | ||
| await IncognitoChats.drain() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: This drain happens after incognito workspaces and their terminals have already been closed, so a terminal agent can disappear before acpmux scans the workspace and its transcript can survive. Await the purge immediately after Prompt for AI agents |
||
| }, | ||
| stopSavingWindows: { services.windows.stopSavingForTermination() }, | ||
| sidebar: { await services.sidebarSnapshots.flush() }, | ||
| homeCache: { await services.home.flushCacheForQuit() }, | ||
|
|
@@ -215,6 +221,8 @@ final class QuitCoordinator { | |
| endLocalSessions: { await services.daemon.endSessionsAndStop(choice) }, | ||
| confirmFailures: { [weak self] failures in await self?.confirm(failures) ?? .quitAnyway }, | ||
| endLocalAgents: { [attempts = QuitAttempts()] in | ||
| // The incognito chats' purges reach acpmux before its agents end. | ||
| await IncognitoChats.drain() | ||
| // The Chief home's host ends with the sessions (home-state-ownership.md section 3), | ||
| // and Home does not start it again. | ||
| await services.home.brain.stop() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import CmuxNextAgentPane | ||
| import CmuxNextDaemon | ||
| import Foundation | ||
| import os | ||
|
|
||
| /// Agent chats of incognito workspaces. An incognito space keeps no history, is never restored and is | ||
| /// deleted when its window closes (leo-workspace design/spaces.md "Incognito"; Leo, 2026-10-31: "like | ||
| /// incognito spaces, each new window is a space"). A chat started in one is an ordinary acpmux chat, | ||
| /// so when the workspace closes (its window, Delete Space, quit) acpmux stops and deletes every chat | ||
| /// it recorded for that workspace, and the transcripts they wrote: none lists under Earlier, in the | ||
| /// palette or on New Tab after. acpmux owns which chats those are; the app only names the workspace. | ||
| @MainActor | ||
| enum IncognitoChats { | ||
| /// Purges not answered yet; the quit waits for them before acpmux's agents end. | ||
| private static var pending: [UUID: Task<Void, Never>] = [:] | ||
|
|
||
| /// `WorkspaceClose.willClose`: every close path tells it before it closes `workspace`. | ||
| static func closing(_ workspace: WorkspaceModel, _ services: AppServices) { | ||
| guard let key = key(of: workspace.id, key: workspace.key?.rawValue, incognito: services.windows.isIncognito(workspace:)), | ||
| // An attached daemon (`CMUX_NEXT_ACPMUX_ATTACH`) is another app's: its chats are not this app's to delete. | ||
| let acpmux = QuitAgents.environment(services), !acpmux.isAttached else { return } | ||
| let id = UUID() | ||
| // task-owner: this enum until acpmux answers (or its deadline); the quit drains it. | ||
| pending[id] = Task { @MainActor in | ||
| defer { pending[id] = nil } | ||
| do { | ||
| // Within a Keep quit's 4 s deadline (QuitSteps). | ||
| try await acpmux.purgeWorkspace(key: key, deadline: .seconds(3)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The 3-second deadline also applies to ordinary incognito workspace closes, and a timeout only gets logged; a busy or large purge can therefore leave the incognito chats and transcripts behind. Apply the short budget only during quit, while allowing normal closes to use the longer purge deadline or retry until the purge is acknowledged. Prompt for AI agents |
||
| } catch { | ||
| Logger(subsystem: "com.cmuxterm.app.next", category: "incognito") | ||
| .error("incognito chats purge failed: \(String(describing: error), privacy: .public)") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Quit, before the incognito spaces are deleted (deleting a space closes its workspaces without | ||
| /// `WorkspaceClose.willClose`): every incognito window's workspaces purge their chats now. | ||
| static func closingForQuit(_ services: AppServices) { | ||
| let windows = services.windows | ||
| let registry = windows.registry.value | ||
| for window in registry.windows where registry.isIncognito(window.id) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Quit only purges registered incognito windows, so a workspace still in Prompt for AI agents |
||
| for id in window.workspaceIDs { | ||
| guard let (workspace, _) = services.machines.workspace(id: id) else { continue } | ||
| closing(workspace, services) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// The acpmux workspace key whose chats go when workspace `id` closes: its key when it is | ||
| /// incognito, else nil (a normal workspace's chats stay in history). | ||
| static func key(of id: String, key: String?, incognito: (String) -> Bool) -> String? { | ||
| guard let key, !key.isEmpty, incognito(id) else { return nil } | ||
| return key | ||
| } | ||
|
|
||
| /// Quit: the purges of the incognito workspaces just closed reach acpmux before its agents end. | ||
| static func drain() async { | ||
| for task in pending.values { await task.value } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import Testing | ||
| @testable import CmuxNextApp | ||
|
|
||
| /// An incognito workspace's chats are deleted when it closes; a normal workspace's stay in history | ||
| /// (leo-workspace design/spaces.md "Incognito"). | ||
| @MainActor | ||
| struct IncognitoChatsTests { | ||
| private let incognito: Set<String> = ["incognito-workspace"] | ||
|
|
||
| @Test func anIncognitoWorkspacesChatsArePurgedByItsKey() { | ||
| let key = "3f2b8c1e-5d4a-4b6f-9e7d-0a1b2c3d4e5f" | ||
| #expect(IncognitoChats.key(of: "incognito-workspace", key: key, incognito: incognito.contains) == key) | ||
| } | ||
|
|
||
| @Test func aNormalWorkspaceKeepsItsChats() { | ||
| let key = "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" | ||
| #expect(IncognitoChats.key(of: "normal-workspace", key: key, incognito: incognito.contains) == nil) | ||
| } | ||
|
|
||
| @Test func aWorkspaceWithNoKeyPurgesNothing() { | ||
| #expect(IncognitoChats.key(of: "incognito-workspace", key: nil, incognito: incognito.contains) == nil) | ||
| #expect(IncognitoChats.key(of: "incognito-workspace", key: "", incognito: incognito.contains) == nil) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.