Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6f0fd4b
fix(cloud): resume one signed-out machine creation
teamleaderleo Oct 28, 2026
d2fb5a8
test(cloud): cover signed-out creation replacement
teamleaderleo Oct 28, 2026
885c6d6
fix(cloud): capture creation action scope
teamleaderleo Oct 28, 2026
838345b
fix(cloud): invalidate pending creation on account teardown
teamleaderleo Oct 28, 2026
91f2b00
fix(cloud): keep creation through initial sign-in team hydration
teamleaderleo Oct 28, 2026
4803881
fix(cloud): fence stale team restoration
teamleaderleo Oct 28, 2026
51fb852
test(cloud): reject stale team restoration
teamleaderleo Oct 28, 2026
d261748
fix(cloud): require a signed-in user for team hydration
teamleaderleo Oct 28, 2026
c0c7075
test: attach account replacement arguments
teamleaderleo Oct 28, 2026
b324e22
fix: run signed-out creation through action effects
teamleaderleo Oct 28, 2026
aad85c0
fix: report deferred sign-in failures through registry
teamleaderleo Oct 28, 2026
0475461
fix: track creation work and preserve deferred action results
teamleaderleo Oct 28, 2026
84761c8
fix: avoid default asynchronous closure in action bridge
teamleaderleo Oct 28, 2026
d3e574a
fix(actions): preserve interactive failure reporting
teamleaderleo Oct 29, 2026
101aab6
fix(actions): pass refusal capture into tracked work
teamleaderleo Oct 29, 2026
14d9ee4
Merge branch 'feat-cmux-next' into cloud-setup-resume-create
teamleaderleo Oct 30, 2026
b7e8677
Merge branch 'feat-cmux-next' into cloud-setup-resume-create
teamleaderleo Oct 30, 2026
ac6d357
Merge feat-cmux-next into cloud-setup-resume-create
teamleaderleo Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/// 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: (@MainActor () async -> ActionWorkFailure?)? = nil,
cancelAuthorization: @escaping @MainActor () -> Void = {},
start: @escaping @MainActor () -> ActionWorkFailure?
) -> ActionEffect {
let scope = ActionRunScope.current
let capturedByCaller = registry.isCapturingWork
let answers = AsyncStream<Answer>.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, capturedByCaller: capturedByCaller,
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?, capturedByCaller: Bool,
cancellationFailure: ActionWorkFailure,
start: @MainActor () -> ActionWorkFailure?) async -> Answer {
var captured: (work: [ActionWork], results: [ActionResultWork]) = ([], [])
var startFailure: ActionWorkFailure?
let refusal = ActionRunScope.carrying(scope) {
// 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 {
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() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,15 @@ extension AgentPaneModel {
/// Search chats) and the History search page (the + menu's Search pages). They open the app's own
/// palette, as Cmd-K does, so they need no gesture credit.
static let paletteActions: Set<String> = ["agentPane.searchChats", "history.search"]
/// 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" || Self.paletteActions.contains(id) || Self.connectActions.contains(id),
Expand All @@ -35,7 +33,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).")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -304,6 +305,7 @@ final class CloudService {
guard let self else { return }
var last = account
for await account in ObservationStream({ self.account }) where account != last {
signedOutCreation.scopeDidChange(from: last, to: account)
last = account
await codexBroker.scopeDidChange()
startSessionRuns.forgetAll()
Expand All @@ -326,6 +328,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()
Expand All @@ -348,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) }
Expand Down Expand Up @@ -455,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 }
Expand Down Expand Up @@ -680,6 +679,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,9 @@ import CmuxNextWakeups
// and diagnostics.
extension CloudHandlers {
static func bindCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) {
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, chat: CloudChatPick.chatTab(invocation, context.services))
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)
}
bind("newCloudWorkspace", registry, reason: reason) { invocation in
let cloud = context.services.cloud
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
guard let session = target, session.daemon.connection != nil else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -40,6 +40,60 @@ 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
// The chat the person asked from, read now: sign-in may finish after focus moved.
let chat = CloudChatPick.chatTab(invocation, context.services)
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, chat: chat)
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,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) },
// Labs labs.machineJoin: read at each opening, so the switch takes effect on the next one.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/// Owns one person-initiated creation while hosted Cloud sign-in is pending.
/// Replacement, sign-out, and policy changes invalidate the previous generation.
@MainActor
public final class CloudSignedOutCreationContinuation {
private var generation = 0
private var pendingGeneration: Int?

public init() {}

/// Reserves one replay generation synchronously, before an action hands its
/// asynchronous sign-in work to ActionRegistry.
public 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
public func perform(
expected: Int,
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
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.
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()
}

public func cancel() {
generation += 1
pendingGeneration = nil
}

public func cancel(expected: Int) {
guard pendingGeneration == expected, generation == expected else { return }
cancel()
}

public var isPending: Bool { pendingGeneration != nil }
}
Loading
Loading