From 6f0fd4b407edde3c974ae8b6e8ca30df915a9f12 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 22:25:29 +0000 Subject: [PATCH 01/15] fix(cloud): resume one signed-out machine creation Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../Handlers/CloudHandlers+Account.swift | 30 +++++++-- .../CmuxNextApp/Handlers/CloudHandlers.swift | 2 +- .../CloudSignedOutCreationContinuation.swift | 33 ++++++++++ .../CloudWorkspaceSetupLifecycleTests.swift | 65 +++++++++++++++++++ 4 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index 877b3a4c674d..5a852c4d630a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -14,18 +14,34 @@ import CmuxNextWakeups // and diagnostics. extension CloudHandlers { static func bindCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { + let cloud = context.services.cloud + let signedOutCreation = CloudSignedOutCreationContinuation() bind("newCloudMachine", registry, reason: reason) { invocation in let request = try newMachineRequest(invocation) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil - if request.isDefault, origin(of: invocation) == .user, window != nil { - beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) + let start: @MainActor () -> Void = { + guard cloud.isSignedIn else { return } + // A person’s original gesture owns the window and request. If + // that window has gone away while the browser was open, drop + // the intent rather than creating an orphaned machine. + guard window != nil || origin(of: invocation) != .user else { return } + if request.isDefault, origin(of: invocation) == .user, window != nil { + beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) + return + } + let flow = creationFlow(context, startedByPerson: origin(of: invocation) == .user, + askedBy: invocation.callerAgentSession) + guard request.isDefault else { return createRequested(request, flow: flow, window: window, context) } + reportCreatedMachine(flow.start(in: window, existing: nil), context) + } + guard !cloud.isSignedIn else { + start() return } - let flow = creationFlow(context, startedByPerson: invocation.origin == .user, askedBy: invocation.callerAgentSession) - guard request.isDefault else { return createRequested(request, flow: flow, window: window, context) } - // The new machine's progress shows in the window at once; its - // first workspace opens there when its daemon is ready. - reportCreatedMachine(flow.start(in: window, existing: nil), context) + guard origin(of: invocation) == .user else { + throw ActionFailure(message: CloudStrings.signInFirst) + } + signedOutCreation.replace(signIn: { await cloud.auth.signIn() }, resume: start) } bind("newCloudWorkspace", registry, reason: reason) { invocation in let target = try workspaceMachine(invocation, context) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift index b7cb2df63b2b..5de3c6dc07ed 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift @@ -24,7 +24,7 @@ enum CloudHandlers { bindDomainActions(into: registry, context: context, reason: signedInReason) bindListActions(into: registry, context: context) CloudStartSession.bind(into: registry, context: context, reason: signedInReason) - bindCreation(into: registry, context: context, reason: signedInReason) + bindCreation(into: registry, context: context, reason: { cloud.unavailableReason }) bindAccount(into: registry, context: context, reason: reason) bindMove(into: registry, context: context) // Any catalog row added later without a handler stays covered. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift new file mode 100644 index 000000000000..55d547c395a8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Owns one person-initiated creation handoff while hosted Cloud sign-in is pending. +/// A replacement click or cancellation invalidates the previous generation, so a +/// late auth callback cannot create a second machine or replay an old request. +@MainActor +final class CloudSignedOutCreationContinuation { + private var generation = 0 + private var task: Task? + + func replace( + signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void + ) { + generation += 1 + let expected = generation + task?.cancel() + task = Task { @MainActor [weak self] in + guard let self else { return } + guard await signIn(), !Task.isCancelled, self.generation == expected else { return } + resume() + if self.generation == expected { self.task = nil } + } + } + + func cancel() { + generation += 1 + task?.cancel() + task = nil + } + + var isPending: Bool { task != nil } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index af30ddc038fb..1cc3fe56b41f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -64,3 +64,68 @@ struct CloudWorkspaceSetupLifecycleTests { #expect(CloudHandlers.workspaceSetupReadiness(creation, in: creations) == .ended) } } + +@MainActor +private final class CloudSignInTestGate { + private var result: CheckedContinuation? + private var requested: CheckedContinuation? + + func waitUntilRequested() async { + await withCheckedContinuation { requested = $0 } + } + + func wait() async -> Bool { + requested?.resume() + requested = nil + return await withCheckedContinuation { result = $0 } + } + + func resolve(_ value: Bool) { + result?.resume(returning: value) + result = nil + } +} + +@MainActor @Suite(.serialized) +struct CloudSignedOutCreationContinuationTests { + @Test func resumesExactlyOnceAfterSignIn() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + gate.resolve(true) + while resumes == 0 { await Task.yield() } + #expect(resumes == 1) + } + + @Test func replacementIgnoresLateResultFromTheOriginalIntent() async { + let handoff = CloudSignedOutCreationContinuation() + let first = CloudSignInTestGate() + let second = CloudSignInTestGate() + var resumes = 0 + handoff.replace(signIn: { await first.wait() }, resume: { resumes += 1 }) + await first.waitUntilRequested() + handoff.replace(signIn: { await second.wait() }, resume: { resumes += 1 }) + await second.waitUntilRequested() + first.resolve(true) + await Task.yield() + #expect(resumes == 0) + second.resolve(true) + while resumes == 0 { await Task.yield() } + #expect(resumes == 1) + } + + @Test func cancellationIgnoresLateSignInResult() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.cancel() + gate.resolve(true) + await Task.yield() + #expect(resumes == 0) + #expect(!handoff.isPending) + } +} From d2fb5a8b70dec8de20f28707e63f6313dc2b8e72 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 22:26:03 +0000 Subject: [PATCH 02/15] test(cloud): cover signed-out creation replacement Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CloudWorkspaceSetupLifecycleTests.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index 1cc3fe56b41f..57e94dcbf081 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -65,6 +65,14 @@ struct CloudWorkspaceSetupLifecycleTests { } } +@MainActor +private final class CloudSignInTestSignal { + private var continuation: CheckedContinuation? + + func wait() async { await withCheckedContinuation { continuation = $0 } } + func fire() { continuation?.resume(); continuation = nil } +} + @MainActor private final class CloudSignInTestGate { private var result: CheckedContinuation? @@ -91,11 +99,12 @@ struct CloudSignedOutCreationContinuationTests { @Test func resumesExactlyOnceAfterSignIn() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() + let resumed = CloudSignInTestSignal() var resumes = 0 - handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1; resumed.fire() }) await gate.waitUntilRequested() gate.resolve(true) - while resumes == 0 { await Task.yield() } + await resumed.wait() #expect(resumes == 1) } @@ -103,16 +112,17 @@ struct CloudSignedOutCreationContinuationTests { let handoff = CloudSignedOutCreationContinuation() let first = CloudSignInTestGate() let second = CloudSignInTestGate() + let resumed = CloudSignInTestSignal() var resumes = 0 - handoff.replace(signIn: { await first.wait() }, resume: { resumes += 1 }) + handoff.replace(signIn: { await first.wait() }, resume: { resumes += 1; resumed.fire() }) await first.waitUntilRequested() - handoff.replace(signIn: { await second.wait() }, resume: { resumes += 1 }) + handoff.replace(signIn: { await second.wait() }, resume: { resumes += 1; resumed.fire() }) await second.waitUntilRequested() first.resolve(true) await Task.yield() #expect(resumes == 0) second.resolve(true) - while resumes == 0 { await Task.yield() } + await resumed.wait() #expect(resumes == 1) } From 885c6d627724a4d354e1985a0830c7a691da8bef Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 22:29:02 +0000 Subject: [PATCH 03/15] fix(cloud): capture creation action scope Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CmuxNextApp/Handlers/CloudHandlers+Account.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index 5a852c4d630a..b48d1aaf1507 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -18,18 +18,19 @@ extension CloudHandlers { let signedOutCreation = CloudSignedOutCreationContinuation() bind("newCloudMachine", registry, reason: reason) { invocation in let request = try newMachineRequest(invocation) + let actionOrigin = origin(of: invocation) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil let start: @MainActor () -> Void = { guard cloud.isSignedIn else { return } // A person’s original gesture owns the window and request. If // that window has gone away while the browser was open, drop // the intent rather than creating an orphaned machine. - guard window != nil || origin(of: invocation) != .user else { return } - if request.isDefault, origin(of: invocation) == .user, window != nil { + guard window != nil || actionOrigin != .user else { return } + if request.isDefault, actionOrigin == .user, window != nil { beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) return } - let flow = creationFlow(context, startedByPerson: origin(of: invocation) == .user, + let flow = creationFlow(context, startedByPerson: actionOrigin == .user, askedBy: invocation.callerAgentSession) guard request.isDefault else { return createRequested(request, flow: flow, window: window, context) } reportCreatedMachine(flow.start(in: window, existing: nil), context) @@ -38,7 +39,7 @@ extension CloudHandlers { start() return } - guard origin(of: invocation) == .user else { + guard actionOrigin == .user else { throw ActionFailure(message: CloudStrings.signInFirst) } signedOutCreation.replace(signIn: { await cloud.auth.signIn() }, resume: start) From 838345b56fb1c1139d452029729b2370d3f92f7b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 22:43:52 +0000 Subject: [PATCH 04/15] fix(cloud): invalidate pending creation on account teardown Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../AgentPaneModel+Actions.swift | 9 +-- .../CmuxNextApp/Cloud/CloudService.swift | 6 ++ .../Handlers/CloudHandlers+Account.swift | 18 +++-- .../CloudSignedOutCreationContinuation.swift | 28 ++++--- .../Remote/AddDeviceServices+App.swift | 3 +- .../AgentPaneConnectActionTests.swift | 10 +++ .../CloudWorkspaceSetupLifecycleTests.swift | 78 ++++++++++++------- 7 files changed, 95 insertions(+), 57 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift index 75c4c481b305..b3f1e664ece2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift @@ -12,17 +12,15 @@ extension AgentPaneModel { ] /// The arguments a connect action runs with. static let connectArguments: [String: [String: String]] = ["palette.openCloudPane": ["route": "/get"]] - /// What runs when a connect is refused: signed out, cmux Cloud… starts sign-in. - static let connectFallbacks: [String: String] = ["newCloudMachine": "palette.auth.signIn"] /// `action.run` from the page. Any agent tab may open the New Tab /// page (a blank chat's New), the command palette's chats page ("Show all", decision K1), and the /// location row's SSH and cmux Cloud connect flows (Lawrence 2026-10-06: "I cannot click on cmux /// Cloud SSH"). Integrate a harness is the page's own Harnesses page (cx-uj5g8). /// A connect flow opens app UI, so it uses the user's fresh gesture in the pane (the grant - /// credit, as Choose Folder…); page script cannot make one. Its sign-in fallback runs only - /// inside that same gesture. An action the app did not run (turned off, or not for this tab), with - /// no fallback that ran, is an `unavailable` failure, so the page can say so; one that ran and + /// credit, as Choose Folder…); page script cannot make one. The native creation handler owns + /// sign-in and its continuation. An action the app did not run (turned off, or not for this tab) + /// is an `unavailable` failure, so the page can say so; one that ran and /// then stopped reports itself in the app (its refusal or toast). func runAction(_ id: String) -> [String: Any] { guard id == "newTab.page" || id == "agentPane.searchChats" || Self.connectActions.contains(id), @@ -31,7 +29,6 @@ extension AgentPaneModel { } if Self.connectActions.contains(id), !transport.gestures.consume() { return Self.transportFailure(.gestureRequired) } if onRunAction(id, Self.connectArguments[id] ?? [:]) { return AgentPaneReply.success() } - if let fallback = Self.connectFallbacks[id], onRunAction(fallback, [:]) { return AgentPaneReply.success() } return AgentPaneReply.failure(code: "unavailable", message: "The app did not run \(id).") } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 810293fd0cd2..60bf65f42271 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -107,6 +107,7 @@ final class CloudService { @ObservationIgnored let startSessionAsks = CloudStartSessionAsks() /// `cmux cloud start-session` runs, joined by a repeat of the same ask (``CloudStartSessionRuns``). @ObservationIgnored let startSessionRuns = CloudStartSessionRuns() + @ObservationIgnored let signedOutCreation = CloudSignedOutCreationContinuation() /// When the loaded list landed (``hasCurrentList(within:)``). @ObservationIgnored private var listLoadedAt: ContinuousClock.Instant? /// Moved chats' file requests for this Mac, asked in their chats. @@ -304,6 +305,9 @@ final class CloudService { guard let self else { return } var last = account for await account in ObservationStream({ self.account }) where account != last { + // The initial sign-in completes the pending intent. Later scope + // changes invalidate it, including sign-out before auth returns. + if last[0] != nil || account[0] == nil { signedOutCreation.cancel() } last = account await codexBroker.scopeDidChange() startSessionRuns.forgetAll() @@ -326,6 +330,7 @@ final class CloudService { // homes for a later confirmed run after policy is restored. codexBroker.setAvailable(!disabled) if disabled { + signedOutCreation.cancel() for session in machines.cloud { session.daemon.policyBlock.set(true) session.disconnect() @@ -680,6 +685,7 @@ final class CloudService { var willSignOut: [@MainActor () -> Void] = [] func signOut() async { + signedOutCreation.cancel() // Start-session asks and runs end now, not after the link and install cleanup below. startSessionRuns.forgetAll() startSessionAsks.endAll() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index b48d1aaf1507..9a6594959e05 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -15,17 +15,13 @@ import CmuxNextWakeups extension CloudHandlers { static func bindCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { let cloud = context.services.cloud - let signedOutCreation = CloudSignedOutCreationContinuation() + let signedOutCreation = cloud.signedOutCreation bind("newCloudMachine", registry, reason: reason) { invocation in let request = try newMachineRequest(invocation) let actionOrigin = origin(of: invocation) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil let start: @MainActor () -> Void = { - guard cloud.isSignedIn else { return } - // A person’s original gesture owns the window and request. If - // that window has gone away while the browser was open, drop - // the intent rather than creating an orphaned machine. - guard window != nil || actionOrigin != .user else { return } + guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } if request.isDefault, actionOrigin == .user, window != nil { beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) return @@ -36,15 +32,21 @@ extension CloudHandlers { reportCreatedMachine(flow.start(in: window, existing: nil), context) } guard !cloud.isSignedIn else { + signedOutCreation.cancel() start() return } guard actionOrigin == .user else { throw ActionFailure(message: CloudStrings.signInFirst) } - signedOutCreation.replace(signIn: { await cloud.auth.signIn() }, resume: start) + signedOutCreation.replace(signIn: { await cloud.auth.signIn() }) { + // Keep the original destination even if another window became + // active; closing it cancels the deferred creation. + if let window, context.services.windows.controller(for: window.id) == nil { return } + start() + } } - bind("newCloudWorkspace", registry, reason: reason) { invocation in + bind("newCloudWorkspace", registry, reason: { reason() ?? (cloud.isSignedIn ? nil : CloudStrings.signInFirst) }) { invocation in let target = try workspaceMachine(invocation, context) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil guard let session = target, session.daemon.connection != nil else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift index 55d547c395a8..e9864504e3cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -1,26 +1,30 @@ -import Foundation - -/// Owns one person-initiated creation handoff while hosted Cloud sign-in is pending. -/// A replacement click or cancellation invalidates the previous generation, so a -/// late auth callback cannot create a second machine or replay an old request. +/// Owns one person-initiated creation while hosted Cloud sign-in is pending. +/// Replacement, sign-out, and policy changes invalidate the previous generation. @MainActor final class CloudSignedOutCreationContinuation { private var generation = 0 private var task: Task? + /// The auth attempt has its own bounded lifetime. Cancelling this waiter + /// leaves that shared attempt alone, but never resumes its creation. + @discardableResult func replace( signIn: @escaping @MainActor () async -> Bool, resume: @escaping @MainActor () -> Void - ) { - generation += 1 + ) -> Task { + cancel() let expected = generation - task?.cancel() - task = Task { @MainActor [weak self] in - guard let self else { return } - guard await signIn(), !Task.isCancelled, self.generation == expected else { return } + let pending = Task { @MainActor [weak self] in + let succeeded = await signIn() + guard let self, self.generation == expected else { return } + self.task = nil + guard succeeded, !Task.isCancelled else { return } + // Consume before calling out; replay may itself start another intent. + self.generation += 1 resume() - if self.generation == expected { self.task = nil } } + task = pending + return pending } func cancel() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift index 8f3e0da682b9..46fbfc5e9969 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift @@ -31,8 +31,7 @@ extension AddDeviceServices { enterCode: { [weak services] in _ = services?.registry.perform("server.addServer") }, // Signed out, New Cloud Machine is refused: sign-in starts instead (the agent pane's same fallback). newCloudMachine: { [weak services] in - guard let registry = services?.registry, !registry.perform("newCloudMachine") else { return } - _ = registry.perform("palette.auth.signIn") + _ = services?.registry.perform("newCloudMachine") }, copy: { CloudPresenter.copy($0) }) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift index c98009c5aca8..dd7f4c791e36 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift @@ -34,6 +34,16 @@ struct AgentPaneConnectActionTests { #expect(arguments == [[:]]) } + @Test func refusedCloudCreationDoesNotLoseIntentToStandaloneSignIn() async { + let model = AgentPaneModel(host: NoHost()) + var ran: [String] = [] + model.onRunAction = { id, _ in ran.append(id); return false } + model.transport.gestures.record() + let reply = await model.respond(to: .runAction("newCloudMachine")) + #expect(reply["ok"] as? Bool != true) + #expect(ran == ["newCloudMachine"]) + } + /// Connect Phone… opens the Mobile Pairing page, on the person's gesture only. @Test func connectPhoneOpensMobilePairingOnAGesture() async { let model = AgentPaneModel(host: NoHost()) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index 57e94dcbf081..69f86f96965d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -65,27 +65,22 @@ struct CloudWorkspaceSetupLifecycleTests { } } -@MainActor -private final class CloudSignInTestSignal { - private var continuation: CheckedContinuation? - - func wait() async { await withCheckedContinuation { continuation = $0 } } - func fire() { continuation?.resume(); continuation = nil } -} - @MainActor private final class CloudSignInTestGate { private var result: CheckedContinuation? private var requested: CheckedContinuation? func waitUntilRequested() async { + if result != nil { return } await withCheckedContinuation { requested = $0 } } func wait() async -> Bool { - requested?.resume() - requested = nil - return await withCheckedContinuation { result = $0 } + await withCheckedContinuation { + result = $0 + requested?.resume() + requested = nil + } } func resolve(_ value: Bool) { @@ -94,47 +89,72 @@ private final class CloudSignInTestGate { } } -@MainActor @Suite(.serialized) +@MainActor @Suite(.timeLimit(.minutes(1))) struct CloudSignedOutCreationContinuationTests { - @Test func resumesExactlyOnceAfterSignIn() async { + @Test func resumesTheCapturedIntentOnce() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() - let resumed = CloudSignInTestSignal() - var resumes = 0 - handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1; resumed.fire() }) + var intent = "original" + var resumed: [String] = [] + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { [intent] in resumed.append(intent) }) await gate.waitUntilRequested() + intent = "later" gate.resolve(true) - await resumed.wait() - #expect(resumes == 1) + await pending.value + #expect(resumed == ["original"]) + #expect(intent == "later") + #expect(!handoff.isPending) } @Test func replacementIgnoresLateResultFromTheOriginalIntent() async { let handoff = CloudSignedOutCreationContinuation() let first = CloudSignInTestGate() let second = CloudSignInTestGate() - let resumed = CloudSignInTestSignal() - var resumes = 0 - handoff.replace(signIn: { await first.wait() }, resume: { resumes += 1; resumed.fire() }) + var resumed: [String] = [] + let old = handoff.replace(signIn: { await first.wait() }, resume: { resumed.append("old") }) await first.waitUntilRequested() - handoff.replace(signIn: { await second.wait() }, resume: { resumes += 1; resumed.fire() }) + let latest = handoff.replace(signIn: { await second.wait() }, resume: { resumed.append("latest") }) await second.waitUntilRequested() first.resolve(true) - await Task.yield() - #expect(resumes == 0) + await old.value + #expect(resumed.isEmpty) + #expect(handoff.isPending) second.resolve(true) - await resumed.wait() - #expect(resumes == 1) + await latest.value + #expect(resumed == ["latest"]) + #expect(!handoff.isPending) } - @Test func cancellationIgnoresLateSignInResult() async { + @Test func signOutInvalidationIgnoresLateSignInResult() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() var resumes = 0 - handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) await gate.waitUntilRequested() handoff.cancel() gate.resolve(true) - await Task.yield() + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func dismissedSignInNeverResumesAndClearsPendingIntent() async { + let handoff = CloudSignedOutCreationContinuation() + var resumes = 0 + await handoff.replace(signIn: { false }, resume: { resumes += 1 }).value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func cancelledWaiterCannotResumeEvenIfAuthSucceeded() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + pending.cancel() + gate.resolve(true) + await pending.value #expect(resumes == 0) #expect(!handoff.isPending) } From 91f2b007f4e2613813dcfab4a165343ed05545fe Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 22:51:15 +0000 Subject: [PATCH 05/15] fix(cloud): keep creation through initial sign-in team hydration Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../Cloud/CloudService+ListScope.swift | 6 +++ .../CmuxNextApp/Cloud/CloudService.swift | 10 +---- .../Handlers/CloudHandlers+Account.swift | 32 +------------- .../Handlers/CloudMachineCreationAction.swift | 43 +++++++++++++++++++ .../CloudSignedOutCreationContinuation.swift | 10 +++++ .../CloudWorkspaceSetupLifecycleTests.swift | 28 ++++++++++++ 6 files changed, 90 insertions(+), 39 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift new file mode 100644 index 000000000000..c2374fb6e611 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift @@ -0,0 +1,6 @@ +extension CloudService { + nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { + guard let loadedAt, loadedFor == account else { return false } + return loadedAt.duration(to: now) < age + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 60bf65f42271..a8791640523f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -305,9 +305,7 @@ final class CloudService { guard let self else { return } var last = account for await account in ObservationStream({ self.account }) where account != last { - // The initial sign-in completes the pending intent. Later scope - // changes invalidate it, including sign-out before auth returns. - if last[0] != nil || account[0] == nil { signedOutCreation.cancel() } + signedOutCreation.scopeDidChange(from: last, to: account) last = account await codexBroker.scopeDidChange() startSessionRuns.forgetAll() @@ -353,6 +351,7 @@ final class CloudService { } func stop() { + signedOutCreation.cancel() for observer in observers { observer.cancel() } observers.removeAll() if let linkEvents { machines.local.store.sideEvents.unsubscribe(linkEvents) } @@ -460,11 +459,6 @@ final class CloudService { Self.listIsCurrent(loadedAt: hasLoadedMachines ? listLoadedAt : nil, loadedFor: loadedAccount, account: listAccount, now: .now, within: age) } - nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { - guard let loadedAt, loadedFor == account else { return false } - return loadedAt.duration(to: now) < age - } - private func reconcile(_ list: [CloudMachine]) { // A list that arrives after Cloud was turned off connects nothing. guard !policyDisabled else { return } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index 9a6594959e05..225043ee0abe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -15,37 +15,7 @@ import CmuxNextWakeups extension CloudHandlers { static func bindCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { let cloud = context.services.cloud - let signedOutCreation = cloud.signedOutCreation - bind("newCloudMachine", registry, reason: reason) { invocation in - let request = try newMachineRequest(invocation) - let actionOrigin = origin(of: invocation) - let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil - let start: @MainActor () -> Void = { - guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } - if request.isDefault, actionOrigin == .user, window != nil { - beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) - return - } - let flow = creationFlow(context, startedByPerson: actionOrigin == .user, - askedBy: invocation.callerAgentSession) - guard request.isDefault else { return createRequested(request, flow: flow, window: window, context) } - reportCreatedMachine(flow.start(in: window, existing: nil), context) - } - guard !cloud.isSignedIn else { - signedOutCreation.cancel() - start() - return - } - guard actionOrigin == .user else { - throw ActionFailure(message: CloudStrings.signInFirst) - } - signedOutCreation.replace(signIn: { await cloud.auth.signIn() }) { - // Keep the original destination even if another window became - // active; closing it cancels the deferred creation. - if let window, context.services.windows.controller(for: window.id) == nil { return } - start() - } - } + CloudMachineCreationAction(context: context).bind(into: registry, reason: reason) bind("newCloudWorkspace", registry, reason: { reason() ?? (cloud.isSignedIn ? nil : CloudStrings.signInFirst) }) { invocation in let target = try workspaceMachine(invocation, context) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift new file mode 100644 index 000000000000..b2c6b65d5863 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift @@ -0,0 +1,43 @@ +import CmuxNextActions +import CmuxNextCloud + +/// The shared native action used by the Computer menu, Add Device and palette. +@MainActor +struct CloudMachineCreationAction { + let context: AppActionContext + + func bind(into registry: ActionRegistry, reason: @escaping @MainActor () -> String?) { + let cloud = context.services.cloud + let signedOutCreation = cloud.signedOutCreation + CloudHandlers.bind("newCloudMachine", registry, reason: reason) { invocation in + let request = try CloudHandlers.newMachineRequest(invocation) + let actionOrigin = CloudHandlers.origin(of: invocation) + let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil + let start: @MainActor () -> Void = { + guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } + if request.isDefault, actionOrigin == .user, window != nil { + CloudHandlers.beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) + return + } + let flow = CloudHandlers.creationFlow(context, startedByPerson: actionOrigin == .user, + askedBy: invocation.callerAgentSession) + guard request.isDefault else { return CloudHandlers.createRequested(request, flow: flow, window: window, context) } + CloudHandlers.reportCreatedMachine(flow.start(in: window, existing: nil), context) + } + guard !cloud.isSignedIn else { + signedOutCreation.cancel() + start() + return + } + guard actionOrigin == .user else { + throw ActionFailure(message: CloudStrings.signInFirst) + } + signedOutCreation.replace(signIn: { await cloud.auth.signIn() }) { + // Keep the original destination even if another window became + // active; closing it cancels the deferred creation. + if let window, context.services.windows.controller(for: window.id) == nil { return } + start() + } + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift index e9864504e3cb..603b211427fd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -27,6 +27,16 @@ final class CloudSignedOutCreationContinuation { return pending } + /// Hosted auth publishes its user before loading the initial active team. + /// That nil-to-team hydration belongs to the sign-in we are awaiting. + /// A later user replacement, sign-out, or populated-team change does not. + func scopeDidChange(from previous: [String?], to current: [String?]) { + guard isPending else { return } + if previous[0] == nil, current[0] != nil { return } + if previous[0] == current[0], previous[1] == nil, current[1] != nil { return } + cancel() + } + func cancel() { generation += 1 task?.cancel() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index 69f86f96965d..c5edf32d6115 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -146,6 +146,34 @@ struct CloudSignedOutCreationContinuationTests { #expect(!handoff.isPending) } + @Test func signInUserThenInitialTeamHydrationStillResumes() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: [nil, nil], to: ["user", nil]) + handoff.scopeDidChange(from: ["user", nil], to: ["user", "team"]) + #expect(handoff.isPending) + gate.resolve(true) + await pending.value + #expect(resumes == 1) + } + + @Test(arguments: [["other", "team"], ["user", "other"], ["user", nil], [nil, nil]]) + func accountOrTeamReplacementInvalidatesPendingSignIn(current: [String?]) async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: ["user", "team"], to: current) + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + @Test func cancelledWaiterCannotResumeEvenIfAuthSucceeded() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() From 4803881ca43965183b0a3d0bee42d307fe6f80af Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:08:33 +0000 Subject: [PATCH 06/15] fix(cloud): fence stale team restoration Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../Handlers/CloudSignedOutCreationContinuation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift index 603b211427fd..6eb168003c47 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -32,7 +32,7 @@ final class CloudSignedOutCreationContinuation { /// A later user replacement, sign-out, or populated-team change does not. func scopeDidChange(from previous: [String?], to current: [String?]) { guard isPending else { return } - if previous[0] == nil, current[0] != nil { return } + if previous == [nil, nil], current[0] != nil { return } if previous[0] == current[0], previous[1] == nil, current[1] != nil { return } cancel() } From 51fb8525b0f64510fcf0909e47dfbef506d49bcb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:09:18 +0000 Subject: [PATCH 07/15] test(cloud): reject stale team restoration Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CloudWorkspaceSetupLifecycleTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index c5edf32d6115..fab4cd07e198 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -161,6 +161,19 @@ struct CloudSignedOutCreationContinuationTests { } @Test(arguments: [["other", "team"], ["user", "other"], ["user", nil], [nil, nil]]) + @Test func staleTeamRestorationCannotResumeAnewSignIn() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: [nil, "old-team"], to: ["new-user", "new-team"]) + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + func accountOrTeamReplacementInvalidatesPendingSignIn(current: [String?]) async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() From d26174890c8324be197dde7ed8589c5408c1cf13 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:16:16 +0000 Subject: [PATCH 08/15] fix(cloud): require a signed-in user for team hydration Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CloudSignedOutCreationContinuation.swift | 2 +- .../CloudWorkspaceSetupLifecycleTests.swift | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift index 6eb168003c47..906786f0b28f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -33,7 +33,7 @@ final class CloudSignedOutCreationContinuation { func scopeDidChange(from previous: [String?], to current: [String?]) { guard isPending else { return } if previous == [nil, nil], current[0] != nil { return } - if previous[0] == current[0], previous[1] == nil, current[1] != nil { return } + if previous[0] != nil, previous[0] == current[0], previous[1] == nil, current[1] != nil { return } cancel() } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index fab4cd07e198..8bd449c29c72 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -161,6 +161,19 @@ struct CloudSignedOutCreationContinuationTests { } @Test(arguments: [["other", "team"], ["user", "other"], ["user", nil], [nil, nil]]) + @Test func nilUserTeamHydrationCannotResumeAnewSignIn() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: [nil, nil], to: [nil, "team"]) + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + @Test func staleTeamRestorationCannotResumeAnewSignIn() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() From c0c707504da90aaa4a9d4a027e38dc4a2c42674d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:23:48 +0000 Subject: [PATCH 09/15] test: attach account replacement arguments Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index 8bd449c29c72..413d20b5116d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -160,7 +160,6 @@ struct CloudSignedOutCreationContinuationTests { #expect(resumes == 1) } - @Test(arguments: [["other", "team"], ["user", "other"], ["user", nil], [nil, nil]]) @Test func nilUserTeamHydrationCannotResumeAnewSignIn() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() @@ -187,6 +186,7 @@ struct CloudSignedOutCreationContinuationTests { #expect(!handoff.isPending) } + @Test(arguments: [["other", "team"], ["user", "other"], ["user", nil], [nil, nil]]) func accountOrTeamReplacementInvalidatesPendingSignIn(current: [String?]) async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() From b324e22fdbb6182baad379fb5b56b5a011837498 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:30:59 +0000 Subject: [PATCH 10/15] fix: run signed-out creation through action effects Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../Handlers/CloudMachineCreationAction.swift | 30 ++++++++--- .../CloudSignedOutCreationContinuation.swift | 50 +++++++++++++++---- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift index b2c6b65d5863..d1e58a9c92e8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift @@ -9,7 +9,7 @@ struct CloudMachineCreationAction { func bind(into registry: ActionRegistry, reason: @escaping @MainActor () -> String?) { let cloud = context.services.cloud let signedOutCreation = cloud.signedOutCreation - CloudHandlers.bind("newCloudMachine", registry, reason: reason) { invocation in + let bound = registry.bindEffect("newCloudMachine", requires: nil, unavailable: reason) { invocation in let request = try CloudHandlers.newMachineRequest(invocation) let actionOrigin = CloudHandlers.origin(of: invocation) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil @@ -27,17 +27,31 @@ struct CloudMachineCreationAction { guard !cloud.isSignedIn else { signedOutCreation.cancel() start() - return + return .done } guard actionOrigin == .user else { - throw ActionFailure(message: CloudStrings.signInFirst) + return .refused(CloudStrings.signInFirst) } - signedOutCreation.replace(signIn: { await cloud.auth.signIn() }) { - // Keep the original destination even if another window became - // active; closing it cancels the deferred creation. - if let window, context.services.windows.controller(for: window.id) == nil { return } - start() + let expected = signedOutCreation.reserve() + return .waiting(.network) { + let resumed = await withTaskCancellationHandler { + await signedOutCreation.perform(expected: expected, signIn: { await cloud.auth.signIn() }) { + guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } + // Keep the original destination even if another window became + // active; closing it cancels the deferred creation. + if let window, context.services.windows.controller(for: window.id) == nil { return } + start() + } + } onCancel: { + Task { @MainActor in signedOutCreation.cancel(expected: expected) } + } + // Hosted sign-in cancellation is an expected no-op, like the + // standalone sign-in action; the coordinator has consumed the + // pending intent and will not replay it. + _ = resumed + return nil } } + assert(bound, "newCloudMachine is not in the action catalog") } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift index 906786f0b28f..d7690f5774fb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -3,6 +3,7 @@ @MainActor final class CloudSignedOutCreationContinuation { private var generation = 0 + private var pendingGeneration: Int? private var task: Task? /// The auth attempt has its own bounded lifetime. Cancelling this waiter @@ -12,21 +13,44 @@ final class CloudSignedOutCreationContinuation { signIn: @escaping @MainActor () async -> Bool, resume: @escaping @MainActor () -> Void ) -> Task { - cancel() - let expected = generation + let expected = reserve() let pending = Task { @MainActor [weak self] in - let succeeded = await signIn() - guard let self, self.generation == expected else { return } - self.task = nil - guard succeeded, !Task.isCancelled else { return } - // Consume before calling out; replay may itself start another intent. - self.generation += 1 - resume() + _ = await self?.perform(expected: expected, signIn: signIn, resume: resume) } task = pending return pending } + /// Reserves one replay generation synchronously, before an action hands its + /// asynchronous sign-in work to ActionRegistry. + func reserve() -> Int { + cancel() + pendingGeneration = generation + return generation + } + + /// Completes a reserved sign-in. The registry owns cancellation of this + /// operation; a stale generation can never replay the captured creation. + @discardableResult + func perform( + expected: Int, + signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void + ) async -> Bool { + let succeeded = await signIn() + guard pendingGeneration == expected, generation == expected else { return false } + pendingGeneration = nil + task = nil + guard succeeded, !Task.isCancelled else { + generation += 1 + return false + } + // Consume before calling out; replay may itself start another intent. + generation += 1 + resume() + return true + } + /// Hosted auth publishes its user before loading the initial active team. /// That nil-to-team hydration belongs to the sign-in we are awaiting. /// A later user replacement, sign-out, or populated-team change does not. @@ -39,9 +63,15 @@ final class CloudSignedOutCreationContinuation { func cancel() { generation += 1 + pendingGeneration = nil task?.cancel() task = nil } - var isPending: Bool { task != nil } + func cancel(expected: Int) { + guard pendingGeneration == expected, generation == expected else { return } + cancel() + } + + var isPending: Bool { pendingGeneration != nil } } From aad85c03200f9d35c03d526fb6f45773b3b8236d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:31:53 +0000 Subject: [PATCH 11/15] fix: report deferred sign-in failures through registry Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../Handlers/CloudMachineCreationAction.swift | 24 +++------- .../CloudSignedOutCreationContinuation.swift | 21 ++++++++ .../CloudWorkspaceSetupLifecycleTests.swift | 48 +++++++++++++++++++ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift index d1e58a9c92e8..72025a2d8e2b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift @@ -32,24 +32,12 @@ struct CloudMachineCreationAction { guard actionOrigin == .user else { return .refused(CloudStrings.signInFirst) } - let expected = signedOutCreation.reserve() - return .waiting(.network) { - let resumed = await withTaskCancellationHandler { - await signedOutCreation.perform(expected: expected, signIn: { await cloud.auth.signIn() }) { - guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } - // Keep the original destination even if another window became - // active; closing it cancels the deferred creation. - if let window, context.services.windows.controller(for: window.id) == nil { return } - start() - } - } onCancel: { - Task { @MainActor in signedOutCreation.cancel(expected: expected) } - } - // Hosted sign-in cancellation is an expected no-op, like the - // standalone sign-in action; the coordinator has consumed the - // pending intent and will not replay it. - _ = resumed - return nil + return signedOutCreation.effect(signIn: { await cloud.auth.signIn() }) { + guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } + // Keep the original destination even if another window became + // active; closing it cancels the deferred creation. + if let window, context.services.windows.controller(for: window.id) == nil { return } + start() } } assert(bound, "newCloudMachine is not in the action catalog") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift index d7690f5774fb..4aa6949eda37 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift @@ -1,3 +1,5 @@ +import CmuxNextActions + /// Owns one person-initiated creation while hosted Cloud sign-in is pending. /// Replacement, sign-out, and policy changes invalidate the previous generation. @MainActor @@ -29,6 +31,21 @@ final class CloudSignedOutCreationContinuation { return generation } + func effect(signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void) -> ActionEffect { + let expected = reserve() + // action-wait-allow: hosted authentication must succeed before provisioning; the registry owns this wait. + return .waiting(.network) { + let resumed = await withTaskCancellationHandler { + await self.perform(expected: expected, signIn: signIn, resume: resume) + } onCancel: { + // task-owner: a one-shot cancellation hop invalidates only this generation. + Task { @MainActor in self.cancel(expected: expected) } + } + return resumed ? nil : ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.signInFirst) + } + } + /// Completes a reserved sign-in. The registry owns cancellation of this /// operation; a stale generation can never replay the captured creation. @discardableResult @@ -37,6 +54,10 @@ final class CloudSignedOutCreationContinuation { signIn: @escaping @MainActor () async -> Bool, resume: @escaping @MainActor () -> Void ) async -> Bool { + guard pendingGeneration == expected, generation == expected, !Task.isCancelled else { + cancel(expected: expected) + return false + } let succeeded = await signIn() guard pendingGeneration == expected, generation == expected else { return false } pendingGeneration = nil diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index 413d20b5116d..af8c7ef22614 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -1,4 +1,5 @@ @testable import CmuxNextApp +import CmuxNextActions import CmuxNextCloudUI import CmuxNextCompat import Testing @@ -91,6 +92,53 @@ private final class CloudSignInTestGate { @MainActor @Suite(.timeLimit(.minutes(1))) struct CloudSignedOutCreationContinuationTests { + @Test func registryOwnsSignInAndReportsFailure() async throws { + let handoff = CloudSignedOutCreationContinuation() + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + var signIns = 0, resumes = 0 + registry.bindEffect("test.create") { _ in + handoff.effect(signIn: { signIns += 1; return false }, resume: { resumes += 1 }) + } + let work = registry.capturingWork { registry.perform("test.create") } + #expect(signIns == 0) + #expect(work.count == 1) + #expect(registry.waitingRuns["test.create"]?[.network] == 1) + let pending = try #require(work.first) + #expect(await pending.value != nil) + #expect(signIns == 1) + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func cancelledRegistryWorkReportsFailureAndNeverReplays() async throws { + let handoff = CloudSignedOutCreationContinuation() + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let gate = CloudSignInTestGate() + var resumes = 0 + registry.bindEffect("test.create") { _ in + handoff.effect(signIn: { await gate.wait() }, resume: { resumes += 1 }) + } + let pending = try #require(registry.capturingWork { registry.perform("test.create") }.first) + await gate.waitUntilRequested() + pending.cancel() + gate.resolve(true) + #expect(await pending.value != nil) + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func replacedReservationNeverStartsAuthentication() async { + let handoff = CloudSignedOutCreationContinuation() + let old = handoff.reserve() + _ = handoff.reserve() + var signIns = 0 + let resumed = await handoff.perform(expected: old, signIn: { signIns += 1; return true }, resume: {}) + #expect(!resumed) + #expect(signIns == 0) + #expect(handoff.isPending) + handoff.cancel() + } + @Test func resumesTheCapturedIntentOnce() async { let handoff = CloudSignedOutCreationContinuation() let gate = CloudSignInTestGate() From 0475461e783f444e18a503f9c6881275eb88f1ea Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:55:01 +0000 Subject: [PATCH 12/15] fix: track creation work and preserve deferred action results Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CmuxNextActions/ActionTrackedWork.swift | 72 ++++++++++++++ .../Cloud/CloudService+AppServer.swift | 7 ++ .../Cloud/CloudService+ListScope.swift | 6 -- .../Handlers/CloudHandlers+Account.swift | 2 +- .../CmuxNextApp/Handlers/CloudHandlers.swift | 52 ++++++++++ .../Handlers/CloudMachineCreationAction.swift | 45 --------- .../CloudSignedOutCreationContinuation.swift | 50 ++-------- .../CloudWorkspaceSetupLifecycleTests.swift | 96 +++++++++++++++++++ 8 files changed, 236 insertions(+), 94 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift delete mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift delete mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift rename Packages/macOS/CmuxNext/Sources/{CmuxNextApp/Handlers => CmuxNextCloud/Auth}/CloudSignedOutCreationContinuation.swift (53%) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift new file mode 100644 index 000000000000..a55cb916b9dc --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift @@ -0,0 +1,72 @@ +/// Returns one registry-owned effect for helpers that report work and result tasks. +/// Existing helper deadlines and result fields remain intact. +@MainActor +public enum ActionTrackedWork { + typealias Answer = Result<[String: ActionResultValue], ActionWorkFailure> + + public static func effect( + registry: ActionRegistry, + cancellationFailure: ActionWorkFailure, + waiting: ActionWait = .network, + authorize: @escaping @MainActor () async -> ActionWorkFailure? = { nil }, + cancelAuthorization: @escaping @MainActor () -> Void = {}, + start: @escaping @MainActor () -> ActionWorkFailure? + ) -> ActionEffect { + let scope = ActionRunScope.current + let capturedByCaller = registry.isCapturingWork + let answers = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + if capturedByCaller { + // task-owner: result-only relay; the registry effect below always finishes its stream. + registry.track(result: Task { + for await answer in answers.stream { return answer } + return .failure(cancellationFailure) + }) + } + // action-wait-allow: the caller names its service wait; this adapter preserves helper work and result tracking. + return .waiting(waiting) { + let answer = await withTaskCancellationHandler { + if let failure = await authorize() { return Answer.failure(failure) } + guard !Task.isCancelled else { return Answer.failure(cancellationFailure) } + return await run(registry: registry, scope: scope, cancellationFailure: cancellationFailure, start: start) + } onCancel: { + // task-owner: one-shot cancellation invalidates the reserved sign-in generation. + Task { @MainActor in cancelAuthorization() } + } + answers.continuation.yield(answer) + answers.continuation.finish() + if case .failure(let failure) = answer { return failure } + return nil + } + } + + private static func run(registry: ActionRegistry, scope: ActionRunScope?, cancellationFailure: ActionWorkFailure, + start: @MainActor () -> ActionWorkFailure?) async -> Answer { + var captured: (work: [ActionWork], results: [ActionResultWork]) = ([], []) + var startFailure: ActionWorkFailure? + let refusal = ActionRunScope.carrying(scope) { + registry.capturingRefusal { + captured = registry.capturingRun { startFailure = start() } + } + } + let work = captured.work, results = captured.results + return await withTaskCancellationHandler { + var failure = startFailure ?? refusal.map { ActionWorkFailure(refusal: .unavailable, reason: $0) } + for task in work { + if let next = await task.value, failure == nil { failure = next } + } + guard !Task.isCancelled else { return .failure(cancellationFailure) } + if let failure { return .failure(failure) } + var fields: [String: ActionResultValue] = [:] + for task in results { + switch await task.value { + case .success(let next): fields.merge(next) { _, new in new } + case .failure(let failure): return .failure(failure) + } + } + return .success(fields) + } onCancel: { + for task in work { task.cancel() } + for task in results { task.cancel() } + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift index ff951c5230f8..8d95062e25a7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift @@ -149,3 +149,10 @@ private nonisolated final class CloudRefuseRedirects: NSObject, URLSessionTaskDe completionHandler(nil) } } + +extension CloudService { + nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { + guard let loadedAt, loadedFor == account else { return false } + return loadedAt.duration(to: now) < age + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift deleted file mode 100644 index c2374fb6e611..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+ListScope.swift +++ /dev/null @@ -1,6 +0,0 @@ -extension CloudService { - nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { - guard let loadedAt, loadedFor == account else { return false } - return loadedAt.duration(to: now) < age - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index 225043ee0abe..9b3c10664fdd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -15,7 +15,7 @@ import CmuxNextWakeups extension CloudHandlers { static func bindCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { let cloud = context.services.cloud - CloudMachineCreationAction(context: context).bind(into: registry, reason: reason) + bindMachineCreation(into: registry, context: context, reason: reason) bind("newCloudWorkspace", registry, reason: { reason() ?? (cloud.isSignedIn ? nil : CloudStrings.signInFirst) }) { invocation in let target = try workspaceMachine(invocation, context) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift index 5de3c6dc07ed..605f4995bf5e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift @@ -40,6 +40,58 @@ enum CloudHandlers { assert(bound, "\(id) is not in the action catalog") } + static func bindMachineCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { + let cloud = context.services.cloud + let signedOutCreation = cloud.signedOutCreation + let bound = registry.bindEffect("newCloudMachine", requires: nil, unavailable: reason) { invocation in + let request = try CloudHandlers.newMachineRequest(invocation) + let actionOrigin = CloudHandlers.origin(of: invocation) + let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil + let start: @MainActor () -> ActionWorkFailure? = { + guard cloud.isSignedIn else { return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.signInFirst) } + if let reason = cloud.unavailableReason { return ActionWorkFailure(refusal: .unavailable, reason: reason) } + if let window, context.services.windows.controller(for: window.id) == nil { + return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined) + } + if request.isDefault, actionOrigin == .user, window != nil { + CloudHandlers.beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) + return nil + } + let flow = CloudHandlers.creationFlow(context, startedByPerson: actionOrigin == .user, + askedBy: invocation.callerAgentSession) + guard request.isDefault else { + CloudHandlers.createRequested(request, flow: flow, window: window, context) + return nil + } + CloudHandlers.reportCreatedMachine(flow.start(in: window, existing: nil), context) + return nil + } + guard !cloud.isSignedIn else { + signedOutCreation.cancel() + return ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined), start: start) + } + guard actionOrigin == .user else { + return .refused(CloudStrings.signInFirst) + } + let expected = signedOutCreation.reserve() + return ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined), authorize: { + let authorized = await signedOutCreation.perform(expected: expected, + signIn: { await cloud.auth.signIn() }, resume: {}) + guard authorized, cloud.isSignedIn else { + return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.signInFirst) + } + if let reason = cloud.unavailableReason { + return ActionWorkFailure(refusal: .unavailable, reason: reason) + } + if let window, context.services.windows.controller(for: window.id) == nil { + return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined) + } + return nil + }, cancelAuthorization: { signedOutCreation.cancel(expected: expected) }, start: start) + } + assert(bound, "newCloudMachine is not in the action catalog") + } + // MARK: Resolution /// The Cloud machine an invocation acts on. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift deleted file mode 100644 index 72025a2d8e2b..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudMachineCreationAction.swift +++ /dev/null @@ -1,45 +0,0 @@ -import CmuxNextActions -import CmuxNextCloud - -/// The shared native action used by the Computer menu, Add Device and palette. -@MainActor -struct CloudMachineCreationAction { - let context: AppActionContext - - func bind(into registry: ActionRegistry, reason: @escaping @MainActor () -> String?) { - let cloud = context.services.cloud - let signedOutCreation = cloud.signedOutCreation - let bound = registry.bindEffect("newCloudMachine", requires: nil, unavailable: reason) { invocation in - let request = try CloudHandlers.newMachineRequest(invocation) - let actionOrigin = CloudHandlers.origin(of: invocation) - let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil - let start: @MainActor () -> Void = { - guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } - if request.isDefault, actionOrigin == .user, window != nil { - CloudHandlers.beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession) - return - } - let flow = CloudHandlers.creationFlow(context, startedByPerson: actionOrigin == .user, - askedBy: invocation.callerAgentSession) - guard request.isDefault else { return CloudHandlers.createRequested(request, flow: flow, window: window, context) } - CloudHandlers.reportCreatedMachine(flow.start(in: window, existing: nil), context) - } - guard !cloud.isSignedIn else { - signedOutCreation.cancel() - start() - return .done - } - guard actionOrigin == .user else { - return .refused(CloudStrings.signInFirst) - } - return signedOutCreation.effect(signIn: { await cloud.auth.signIn() }) { - guard cloud.isSignedIn, cloud.unavailableReason == nil else { return } - // Keep the original destination even if another window became - // active; closing it cancels the deferred creation. - if let window, context.services.windows.controller(for: window.id) == nil { return } - start() - } - } - assert(bound, "newCloudMachine is not in the action catalog") - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Auth/CloudSignedOutCreationContinuation.swift similarity index 53% rename from Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Auth/CloudSignedOutCreationContinuation.swift index 4aa6949eda37..2b62fe7b187d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudSignedOutCreationContinuation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Auth/CloudSignedOutCreationContinuation.swift @@ -1,55 +1,24 @@ -import CmuxNextActions - /// Owns one person-initiated creation while hosted Cloud sign-in is pending. /// Replacement, sign-out, and policy changes invalidate the previous generation. @MainActor -final class CloudSignedOutCreationContinuation { +public final class CloudSignedOutCreationContinuation { private var generation = 0 private var pendingGeneration: Int? - private var task: Task? - /// The auth attempt has its own bounded lifetime. Cancelling this waiter - /// leaves that shared attempt alone, but never resumes its creation. - @discardableResult - func replace( - signIn: @escaping @MainActor () async -> Bool, - resume: @escaping @MainActor () -> Void - ) -> Task { - let expected = reserve() - let pending = Task { @MainActor [weak self] in - _ = await self?.perform(expected: expected, signIn: signIn, resume: resume) - } - task = pending - return pending - } + public init() {} /// Reserves one replay generation synchronously, before an action hands its /// asynchronous sign-in work to ActionRegistry. - func reserve() -> Int { + public func reserve() -> Int { cancel() pendingGeneration = generation return generation } - func effect(signIn: @escaping @MainActor () async -> Bool, - resume: @escaping @MainActor () -> Void) -> ActionEffect { - let expected = reserve() - // action-wait-allow: hosted authentication must succeed before provisioning; the registry owns this wait. - return .waiting(.network) { - let resumed = await withTaskCancellationHandler { - await self.perform(expected: expected, signIn: signIn, resume: resume) - } onCancel: { - // task-owner: a one-shot cancellation hop invalidates only this generation. - Task { @MainActor in self.cancel(expected: expected) } - } - return resumed ? nil : ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.signInFirst) - } - } - /// Completes a reserved sign-in. The registry owns cancellation of this /// operation; a stale generation can never replay the captured creation. @discardableResult - func perform( + public func perform( expected: Int, signIn: @escaping @MainActor () async -> Bool, resume: @escaping @MainActor () -> Void @@ -61,7 +30,6 @@ final class CloudSignedOutCreationContinuation { let succeeded = await signIn() guard pendingGeneration == expected, generation == expected else { return false } pendingGeneration = nil - task = nil guard succeeded, !Task.isCancelled else { generation += 1 return false @@ -75,24 +43,22 @@ final class CloudSignedOutCreationContinuation { /// Hosted auth publishes its user before loading the initial active team. /// That nil-to-team hydration belongs to the sign-in we are awaiting. /// A later user replacement, sign-out, or populated-team change does not. - func scopeDidChange(from previous: [String?], to current: [String?]) { + public func scopeDidChange(from previous: [String?], to current: [String?]) { guard isPending else { return } if previous == [nil, nil], current[0] != nil { return } if previous[0] != nil, previous[0] == current[0], previous[1] == nil, current[1] != nil { return } cancel() } - func cancel() { + public func cancel() { generation += 1 pendingGeneration = nil - task?.cancel() - task = nil } - func cancel(expected: Int) { + public func cancel(expected: Int) { guard pendingGeneration == expected, generation == expected else { return } cancel() } - var isPending: Bool { pendingGeneration != nil } + public var isPending: Bool { pendingGeneration != nil } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index af8c7ef22614..70d6a090ac17 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -1,5 +1,6 @@ @testable import CmuxNextApp import CmuxNextActions +import CmuxNextCloud import CmuxNextCloudUI import CmuxNextCompat import Testing @@ -92,6 +93,81 @@ private final class CloudSignInTestGate { @MainActor @Suite(.timeLimit(.minutes(1))) struct CloudSignedOutCreationContinuationTests { + @Test func creationEffectOwnsPostAuthWorkAndPreservesResult() async throws { + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let gate = CloudSignInTestGate() + var starts = 0 + registry.bindEffect("test.create") { _ in + ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled"), authorize: { nil }) { + starts += 1 + registry.track(result: Task { + _ = await gate.wait() + return .success(["machine_id": .string("created"), "pending": .bool(true)]) + }) + return nil + } + } + let captured = registry.capturingRun { registry.perform("test.create") } + #expect(starts == 0, "creation cannot start inside the synchronous handler") + #expect(captured.results.count == 1) + await gate.waitUntilRequested() + #expect(starts == 1) + gate.resolve(true) + for work in captured.work { #expect(await work.value == nil) } + let result = try #require(captured.results.first) + guard case .success(let fields) = await result.value else { + Issue.record("creation result was lost") + return + } + #expect(fields["machine_id"] == .string("created")) + #expect(fields["pending"] == .bool(true)) + } + + @Test(arguments: ["window closed", "cloud unavailable"]) + func postAuthDestinationFailureReachesRegistry(reason: String) async throws { + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let handoff = CloudSignedOutCreationContinuation() + var creations = 0 + registry.bindEffect("test.create") { _ in + let expected = handoff.reserve() + return ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled"), authorize: { + let signedIn = await handoff.perform(expected: expected, signIn: { true }, resume: {}) + return signedIn ? nil : ActionWorkFailure("sign-in failed") + }) { + guard reason.isEmpty else { return ActionWorkFailure(refusal: .unavailable, reason: reason) } + creations += 1 + return nil + } + } + let captured = registry.capturingRun { registry.perform("test.create") } + for work in captured.work { #expect(await work.value?.message == reason) } + #expect(creations == 0) + #expect(!handoff.isPending) + } + + @Test func cancellingCreationEffectCancelsItsTrackedWork() async throws { + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let gate = CloudSignInTestGate() + var child: ActionWork? + registry.bindEffect("test.create") { _ in + ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled")) { + let task: ActionWork = Task { + _ = await gate.wait() + return Task.isCancelled ? ActionWorkFailure("cancelled") : nil + } + child = task + registry.track(task) + return nil + } + } + let work = registry.capturingWork { registry.perform("test.create") } + await gate.waitUntilRequested() + for task in work { task.cancel() } + gate.resolve(true) + for task in work { #expect(await task.value != nil) } + #expect(try #require(child).isCancelled) + } + @Test func registryOwnsSignInAndReportsFailure() async throws { let handoff = CloudSignedOutCreationContinuation() let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) @@ -261,3 +337,23 @@ struct CloudSignedOutCreationContinuationTests { #expect(!handoff.isPending) } } + +/// Tests own their auth tasks; production work is started by ActionRegistry. +@MainActor +private extension CloudSignedOutCreationContinuation { + func replace(signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void) -> Task { + let expected = reserve() + return Task { _ = await perform(expected: expected, signIn: signIn, resume: resume) } + } + + func effect(signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void) -> ActionEffect { + let expected = reserve() + // action-wait-allow: exercise the registry-owned authentication wait. + return .waiting(.network) { + let resumed = await self.perform(expected: expected, signIn: signIn, resume: resume) + return resumed ? nil : ActionWorkFailure("sign-in failed") + } + } +} From 84761c89ddde9df30ed4bb4d0eaf47b0cc253c7c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 23:55:22 +0000 Subject: [PATCH 13/15] fix: avoid default asynchronous closure in action bridge Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift index a55cb916b9dc..006c497fee0d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift @@ -8,7 +8,7 @@ public enum ActionTrackedWork { registry: ActionRegistry, cancellationFailure: ActionWorkFailure, waiting: ActionWait = .network, - authorize: @escaping @MainActor () async -> ActionWorkFailure? = { nil }, + authorize: (@MainActor () async -> ActionWorkFailure?)? = nil, cancelAuthorization: @escaping @MainActor () -> Void = {}, start: @escaping @MainActor () -> ActionWorkFailure? ) -> ActionEffect { @@ -25,7 +25,7 @@ public enum ActionTrackedWork { // action-wait-allow: the caller names its service wait; this adapter preserves helper work and result tracking. return .waiting(waiting) { let answer = await withTaskCancellationHandler { - if let failure = await authorize() { return Answer.failure(failure) } + if let failure = await authorize?() { return Answer.failure(failure) } guard !Task.isCancelled else { return Answer.failure(cancellationFailure) } return await run(registry: registry, scope: scope, cancellationFailure: cancellationFailure, start: start) } onCancel: { From d3e574a706a8eceabe8fdd6aec78f45c31d03cf7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 02:31:12 +0000 Subject: [PATCH 14/15] fix(actions): preserve interactive failure reporting Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../CmuxNextActions/ActionTrackedWork.swift | 12 +++++-- .../ActionEffectTests.swift | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift index 006c497fee0d..1c626c0ab1c5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift @@ -44,9 +44,17 @@ public enum ActionTrackedWork { var captured: (work: [ActionWork], results: [ActionResultWork]) = ([], []) var startFailure: ActionWorkFailure? let refusal = ActionRunScope.carrying(scope) { - registry.capturingRefusal { - captured = registry.capturingRun { startFailure = start() } + // A control-socket/CLI caller captures refusals and receives them + // in its action result. Interactive runs must leave refusal + // reporting enabled so CloudHandlers.runTracked can show its + // normal failure sheet. + if capturedByCaller { + return registry.capturingRefusal { + captured = registry.capturingRun { startFailure = start() } + } } + captured = registry.capturingRun { startFailure = start() } + return nil } let work = captured.work, results = captured.results return await withTaskCancellationHandler { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift index b421055d33c2..6a77b0c4777c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift @@ -87,6 +87,41 @@ import Testing #expect(registry.waitingRuns["test.rename"]?[.daemonEcho] == 2) } + /// Tracked interactive work must leave refusal reporting enabled so the + /// App can present its normal failure notice. A captured CLI run still + /// receives the refusal through its action result. + @Test func trackedWorkKeepsInteractiveFailuresVisibleAndCapturesCliRefusals() async { + let registry = ActionRegistry(catalog: Self.catalog) + var interactiveRefusalCapture = true + var notices: [String] = [] + var onNotice: (() -> Void)? + registry.remoteFailureObserver = { _, notice in + notices.append(notice) + onNotice?() + } + registry.bindEffect("test.rename") { _ in + ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled")) { + interactiveRefusalCapture = registry.isCapturingRefusal + return ActionWorkFailure("create failed") + } + } + await withCheckedContinuation { (done: CheckedContinuation) in + onNotice = { done.resume() } + #expect(registry.perform("test.rename")) + } + #expect(!interactiveRefusalCapture) + #expect(notices.count == 1) + #expect(notices[0].contains("create failed")) + + let cli = registry.capturingWork { + #expect(registry.perform("test.rename", invocation: ActionInvocation(origin: .cli))) + } + var failures: [String?] = [] + for task in cli { failures.append((await task.value)?.message) } + #expect(failures.contains("create failed")) + #expect(notices.count == 1) + } + @Test func aRefusedEffectRefuses() { let registry = ActionRegistry(catalog: Self.catalog) registry.bindEffect("test.rename", effect: { _ in .refused("no name") }) From 101aab62316d43ca06958746aed3e05ce9b02ac4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 02:37:08 +0000 Subject: [PATCH 15/15] fix(actions): pass refusal capture into tracked work Agent-Session: https://cmux.com/deeplink/agent?id=aid-8c81eae1e9c1b9f1f92d --- .../Sources/CmuxNextActions/ActionTrackedWork.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift index 1c626c0ab1c5..3b7355976034 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift @@ -27,7 +27,8 @@ public enum ActionTrackedWork { let answer = await withTaskCancellationHandler { if let failure = await authorize?() { return Answer.failure(failure) } guard !Task.isCancelled else { return Answer.failure(cancellationFailure) } - return await run(registry: registry, scope: scope, cancellationFailure: cancellationFailure, start: start) + return await run(registry: registry, scope: scope, capturedByCaller: capturedByCaller, + cancellationFailure: cancellationFailure, start: start) } onCancel: { // task-owner: one-shot cancellation invalidates the reserved sign-in generation. Task { @MainActor in cancelAuthorization() } @@ -39,7 +40,8 @@ public enum ActionTrackedWork { } } - private static func run(registry: ActionRegistry, scope: ActionRunScope?, cancellationFailure: ActionWorkFailure, + private static func run(registry: ActionRegistry, scope: ActionRunScope?, capturedByCaller: Bool, + cancellationFailure: ActionWorkFailure, start: @MainActor () -> ActionWorkFailure?) async -> Answer { var captured: (work: [ActionWork], results: [ActionResultWork]) = ([], []) var startFailure: ActionWorkFailure?