Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3e95edb
cmux NEXT: New Cloud Space, a space that is one Cloud machine
austinywang Oct 30, 2026
c14447c
cmux NEXT Cloud space: create the machine outside nil-coalescing (asy…
austinywang Oct 30, 2026
eefffde
cmux NEXT Cloud space: Mac-bound creates stay on the Mac; a paused sp…
austinywang Oct 30, 2026
6512ca0
cmux NEXT Cloud space: never an empty window; first launch, opened fi…
austinywang Oct 30, 2026
5aa55b4
cmux NEXT Cloud space: a failed wake falls back without another wait
austinywang Oct 30, 2026
0e379d2
cmux NEXT Cloud space: the fallback adopts default's workspaces from …
austinywang Oct 30, 2026
d7126ed
cmux NEXT Cloud space: a new workspace opens the agent New Tab page, …
austinywang Oct 30, 2026
65dbcf4
cmux NEXT Cloud space: a plain space dot, and the machine leaves Default
austinywang Oct 30, 2026
7eb1ca8
Merge remote-tracking branch 'origin/feat-cmux-next' into sweep-27810
austinywang Oct 30, 2026
bf39da2
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
lawrencecchen Nov 1, 2026
64d4b88
test(cloud-space): a script's create on an unknown machine is refused…
lawrencecchen Nov 1, 2026
5d39eb0
fix(cloud-space): a named machine that does not resolve is refused, n…
lawrencecchen Nov 1, 2026
27b2e57
arch-ratchet: a reviewed app_lines exception of +248 for New Cloud Sp…
lawrencecchen Nov 1, 2026
a5a5f98
test(cloud-space): the unknown-machine create test binds the real act…
lawrencecchen Nov 1, 2026
3f3693f
arch-ratchet: take out the #27810 app_lines exception; #27810 waits f…
lawrencecchen Nov 1, 2026
c79ce70
Merge branch 'feat-cmux-next' into feat-cmux-next-cloud-space
cmux-bot[bot] Nov 1, 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
Expand Up @@ -50,7 +50,7 @@ nonisolated extension ActionSurfaceCatalog {
"space.moveRight", "space.moveToStart", "space.moveToEnd", "space.move", "space.switch", "workspace.moveToSpace", "workspace.duplicateToSpace",
"workspaceGroup.moveToSpace", "space.setTheme", "space.clearTheme",
// Every space action is a verb (Lawrence 14223: no exceptions): the space keys and the color family too.
"space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.bringHere", "space.newIncognito", "space.color.grey", "space.color.blue",
"space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.bringHere", "space.newIncognito", "space.newCloud", "space.color.grey", "space.color.blue",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This adds space.newCloud to cliNamed, which changes the CLI surface, but plans/cmux-next/action-surfaces.json was not regenerated. The export check in ActionSurfaceParityTests.exportIsFresh will fail in CI with "action-surfaces.json is stale". Regenerate with CMUX_UPDATE_ACTION_SURFACES=1 swift test --filter ActionSurfaceParityTests, and refresh web/data/next-docs/cli-reference.generated.json as well if the docs generator requires it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift, line 53:

<comment>This adds `space.newCloud` to `cliNamed`, which changes the CLI surface, but `plans/cmux-next/action-surfaces.json` was not regenerated. The export check in `ActionSurfaceParityTests.exportIsFresh` will fail in CI with "action-surfaces.json is stale". Regenerate with `CMUX_UPDATE_ACTION_SURFACES=1 swift test --filter ActionSurfaceParityTests`, and refresh `web/data/next-docs/cli-reference.generated.json` as well if the docs generator requires it.</comment>

<file context>
@@ -50,7 +50,7 @@ nonisolated extension ActionSurfaceCatalog {
         "workspaceGroup.moveToSpace", "space.setTheme", "space.clearTheme",
         // Every space action is a verb (Lawrence 14223: no exceptions): the space keys and the color family too.
-        "space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.bringHere", "space.newIncognito", "space.color.grey", "space.color.blue",
+        "space.next", "space.previous", "space.selectByNumber", "space.openInNewWindow", "space.bringHere", "space.newIncognito", "space.newCloud", "space.color.grey", "space.color.blue",
         "space.color.red", "space.color.yellow", "space.color.green", "space.color.pink", "space.color.purple",
         "space.color.cyan", "space.color.orange",
</file context>

"space.color.red", "space.color.yellow", "space.color.green", "space.color.pink", "space.color.purple",
"space.color.cyan", "space.color.orange",
"space.newWindow", "space.newWorkspace", "space.rename", "space.setColor",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ nonisolated extension ActionSurfaceCatalog {
// the space actions stay in the palette and the CLI (`.minimalMenu`).
"space.new": [p(.sidebarBackground, .create, 6, folder: .new)],
"space.newIncognito": [p(.sidebarBackground, .create, 6, folder: .new)],
// A Cloud machine opens as its own space from its menu too (RoomHandlers.bindCloudSpace).
"space.newCloud": [p(.sidebarBackground, .create, 6, folder: .new), p(.cloudMachine, .create, 4)],
"space.newWorkspace": [p(.profile, .create, 0, label: SpaceMenuLabels.newWorkspace)],
"space.openInNewWindow": [p(.profile, .create, 1, label: SpaceMenuLabels.openInNewWindow)],
"space.bringHere": [p(.profile, .create, 2, label: SpaceMenuLabels.bringHere)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ nonisolated enum ProfileActionCatalog: ActionCatalogGroup {
surfaces: [.palette, .keyboard, .menu, .contextMenu], arguments: [CatalogArgument.nameString.optional],
cliName: "space new-incognito", mainMenu: .window
),
// Cloud space: a space that is one Cloud machine (its target, else the one New Cloud Workspace uses, else a
// new one). It follows the machine's session and creates there, so switching to it shows the machine's
// own workspaces, terminals and browser tabs (RoomHandlers.bindCloudSpace).
ActionDescriptor(
id: "space.newCloud",
title: String(localized: "action.space.newCloud", defaultValue: "New Cloud Space", table: "ProfileActions", bundle: .module),
keywords: ["room", "profile", "space", "cloud", "vm", "remote", "machine"], category: .workspace, symbol: "cloud",
surfaces: [.palette, .keyboard, .menu, .contextMenu], arguments: [CatalogArgument.nameString.optional],
targets: [.machine], cliName: "space new-cloud", mainMenu: .window
),
// Open Space in New Window (spaces-overhaul-DESIGN.md rule 4): a new window shows the space and the
// window it came from keeps it. The space's right-click menu, a dot dragged out of the switcher,
// the palette, the CLI and MCP run it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7731,6 +7731,137 @@
}
}
},
"action.space.newCloud" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "مساحة سحابية جديدة"
}
},
"bs" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Novi prostor u oblaku"
}
},
"da" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nyt cloud-space"
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Neuer Cloud-Space"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New Cloud Space"
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nuevo espacio en la nube"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nouvel espace cloud"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nuovo spazio cloud"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "新規クラウドスペース"
}
},
"km" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Space ពពកថ្មី"
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "새 클라우드 스페이스"
}
},
"nb" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nytt skyområde"
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nowa przestrzeń w chmurze"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Novo Espaço na Nuvem"
}
},
"ru" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Новое облачное пространство"
}
},
"th" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "สเปซคลาวด์ใหม่"
}
},
"tr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Yeni Bulut Alanı"
}
},
"uk" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Новий хмарний простір"
}
},
"vi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Không gian đám mây mới"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "新建云空间"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "新增雲端空間"
}
}
}
},
"action.space.bringHere" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ extension AgentTabStore {
private func openNewTabColumn(beside provisional: String, cwd: String? = nil, split: Bool = true) -> Bool {
let chat = resolve(provisional)
// The page opens on the chat's machine: that machine's folders, never this Mac's (cx-k21v).
guard let newTab = firstPageNewTab?(cwd ?? lookup(chat)?.store.tab(id: chat)?.cwd, chat) else { return false }
guard let newTab = firstPageNewTab?(cwd ?? lookup(chat)?.store.tab(id: chat)?.cwd, chat, nil) else { return false }
return openBeside(provisional, split: split) { pane, daemon in try self.open(in: pane, of: daemon, newTab: newTab) }
}

Expand Down
40 changes: 26 additions & 14 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ extension AgentTabStore {
func openFirstPage(workspace: WorkspaceHandle, cwd: String?, on daemon: DaemonService,
chat: AgentPaneSeed? = nil) async throws -> SurfaceID? {
guard let connection = daemon.connection, let localHost, canHost(on: daemon) else { throw DaemonError.notConnected }
// A workspace of another machine: its chat runs there (cx-k21v), never in this Mac's acpmux,
// and it gets no New Tab page (that page runs here). Callers open a terminal there instead
// of a first page (``opensFirstPage(on:chat:)``).
// A workspace of another machine: its chat runs there (cx-k21v), never in this Mac's acpmux.
// It gets the New Tab page only where that machine starts chats itself (a Cloud space's
// machine, ``opensRemotePage``); callers open a terminal there otherwise (``opensFirstPage(on:chat:)``).
let remote = !daemon.isLocal
guard !remote || chat != nil else { throw DaemonError.notConnected }
guard !remote || chat != nil || opensRemotePage(daemon) else { throw DaemonError.notConnected }
// A chat of this Mac (an outside chat it resumes, a past chat of its list) never opens in a
// workspace of another machine (cx-k21v).
if remote, chat?.adopt != nil || chat?.pastChat != nil { throw AgentTabRefusal(message: RefusalStrings.agentTabMachineMismatch) }
Expand All @@ -56,7 +56,7 @@ extension AgentTabStore {
return response.surface
}
seeds[key] = AgentPaneSeedSource(AgentPaneSeed(cwd: cwd))
let made = firstPage(cwd: cwd, token: firstPageTokens[workspace])
let made = firstPage(cwd: cwd, token: firstPageTokens[workspace], machine: remote ? daemon : nil)
// The tree listed the tab first and its view already shows the page (with this
// workspace's token): it keeps that page, since a new one remounts the screen and drops
// what was typed (cx-kql5h); only the actions take this folder.
Expand All @@ -77,17 +77,21 @@ extension AgentTabStore {
}

/// Whether a new or empty workspace of `daemon` opens with an agent tab: this Mac's opens the
/// New Tab page; another machine's opens only a chat it asked for (its terminal otherwise).
func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool { canHost(on: daemon) && (daemon.isLocal || chat) }
/// New Tab page, and so does a Cloud machine's whose daemon starts chats there
/// (``opensRemotePage``); another machine's opens only a chat it asked for (its terminal otherwise).
func opensFirstPage(on daemon: DaemonService, chat: Bool = false) -> Bool {
canHost(on: daemon) && (daemon.isLocal || chat || opensRemotePage(daemon))
}

/// The chat seed of new `workspace`, held before its tab is known: the
/// tree can list the tab before new-conversation-tab replies, and a view
/// built then takes it (``firstChatSeed(of:in:)``).
func seedFirstChat(_ seed: AgentPaneSeed, in workspace: WorkspaceHandle) { firstChats[workspace] = AgentPaneSeedSource(seed) }

/// A new workspace's first New Tab page, carrying that workspace's key-hold token.
func firstPage(cwd: String?, token: String?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)? {
guard var made = firstPageNewTab?(cwd, nil) else { return nil }
/// A new workspace's first New Tab page, carrying that workspace's key-hold token. `machine`: the
/// other machine the workspace is on (its folders, no Mac home); nil for this Mac.
func firstPage(cwd: String?, token: String?, machine: DaemonService? = nil) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)? {
guard var made = firstPageNewTab?(cwd, nil, machine) else { return nil }
if let token {
made.page.inputToken = token
onFirstPageToken?(token)
Expand Down Expand Up @@ -198,15 +202,16 @@ extension AgentTabStore {
NewTabPage.replace(key, with: request, cwd: request.cwd ?? cwd, in: controller, keep: tabs?.sessions[key] != nil)
}
}
tabs.firstPageNewTab = { [weak services] cwd, beside in
tabs.firstPageNewTab = { [weak services] cwd, beside, machine in
guard let services else { return nil }
// A page beside a chat of another machine shows that machine's folders (cx-k21v).
// (A chat of this Mac keeps the plain page, as before.)
// A page beside a chat of another machine shows that machine's folders (cx-k21v), and so
// does the first page of a new workspace there (`machine`). (A chat of this Mac keeps the
// plain page, as before.)
let tab = beside.flatMap { services.locateTabOnAnyMachine($0)?.0 }.flatMap { services.machines.daemon(forTab: $0).isLocal ? nil : $0 }
// Without the Commands palette's actions (30-50 ms on the main thread, most of Cmd-N's
// key time: instant-new-surfaces `newTab.paletteActions`): they fill in on the frame
// after the page shows, as Cmd-T's do (``NewTabPage/fillPaletteActions(_:)``).
var page = NewTabPage.page(services, selected: tab, paletteActions: false)
var page = NewTabPage.page(services, selected: tab, machine: machine, paletteActions: false)
page.cwd = cwd ?? page.cwd
NewTabPage.fillPaletteActions(services)
// Keys typed since Cmd-N wait for this page's field (NewTabInputCoordinator, cx-kql5h);
Expand Down Expand Up @@ -335,6 +340,13 @@ extension AgentTabStore {
guard let daemon = try? services.machines.agentDaemon(forTab: tab) else { return nil }
return services.agentGit.link(for: daemon)
}
// A Cloud machine whose daemon starts and attaches chats there opens the New Tab page first,
// as this Mac does: a Cloud space's new workspace is the agent page, its chats on the machine.
tabs.opensRemotePage = { [weak services] daemon in
guard let services, services.machines.cloudSession(of: daemon) != nil else { return false }
return daemon.supports(DaemonCapability.agentSessionAttach.rawValue) && daemon.supports(DaemonCapability.agentSessionStart.rawValue)
&& AgentTabStore.registryHost(of: daemon) != nil
}
tabs.remoteMachineName = { [weak services] daemon in
services?.machines.machineName(daemon.machineID).flatMap(AgentTabStore.displayName)
}
Expand Down
8 changes: 6 additions & 2 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,12 @@ final class AgentTabStore {
/// Opens a redeemed Run attempt's terminal, owned by an enrolled worker daemon, in the tab's pane.
var runAttach: (@MainActor (_ key: String, _ request: AgentPaneRunAttach) async throws -> Void)?
/// The New Tab page a new workspace's first tab shows, starting in the given folder.
/// `(cwd, beside)`: `beside` is the tab whose machine the page opens on (nil: this Mac).
var firstPageNewTab: ((String?, String?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)?)?
/// `(cwd, beside, machine)`: `beside` is the tab whose machine the page opens on, else `machine`
/// (a new workspace of another machine, before its tab is mirrored); nil for both: this Mac.
var firstPageNewTab: ((String?, String?, DaemonService?) -> (page: AgentPaneNewTab, handler: NewTabPageHandler)?)?
/// Whether a new or empty workspace of another machine opens the New Tab page, its chats run
/// there (a Cloud machine whose daemon starts them); otherwise it opens a terminal.
var opensRemotePage: @MainActor (DaemonService) -> Bool = { _ in false }

/// Tabs opened as the chooser page, and the actions for their selected kind.
var newTabPages: [String: (page: AgentPaneNewTab, handler: NewTabPageHandler)] = [:] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,26 @@ extension AppActions {
let target: String? = show || !hasOpenWindow ? windows.targetWindow(preferring: sourceID ?? sourceWindow?.state.id) : nil
// `machine` (App machine id; the CLI's `--session`): born on that
// session's daemon (plans/cmux-next/data-model.md 1.3).
let daemon = (invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue).flatMap(services.machines.daemon(machine:))
if invocation["machine"] != nil, daemon?.connection == nil {
let asked = (invocation["machine"]?.targetValue?.id ?? invocation["machine"]?.stringValue).flatMap(services.machines.daemon(machine:))
// With none asked for, a person's create (or one naming a space) goes where the space creates; a script's
// stays on this Mac, so a `cmux new-workspace` never wakes a paused Cloud machine of the space shown.
let daemon = asked ?? (invocation.origin == .user || spawn.profile != nil ? nil : services.daemon)
// A named machine that does not resolve (unknown, or Cloud off) is refused, never made on this Mac instead.
if invocation["machine"] != nil, asked?.connection == nil {
services.registry.track(Task { ActionWorkFailure(WorkspaceVerbStrings.machineNotConnected) })
return
}
// With no machine asked for, the create goes where the space creates (a Cloud space's machine), so the page
// decision and the key hold below are that daemon's.
let spaceDaemon = windows.spaceCreation(spawn, into: target).daemon ?? services.daemon

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: For an unresolved space, spaceDaemon falls back to services.daemon, so the New Tab page check and the key hold use the Mac even though this create is refused. The comment above says these are "that daemon's", which does not hold in this case. Use an optional spaceDaemon and skip the page and key decision when the space resolves to nothing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppActions+Workspaces.swift, line 166:

<comment>For an unresolved space, `spaceDaemon` falls back to `services.daemon`, so the New Tab page check and the key hold use the Mac even though this create is refused. The comment above says these are "that daemon's", which does not hold in this case. Use an optional `spaceDaemon` and skip the page and key decision when the space resolves to nothing.</comment>

<file context>
@@ -152,17 +152,26 @@ extension AppActions {
         }
+        // With no machine asked for, the create goes where the space creates (a Cloud space's machine), so the page
+        // decision and the key hold below are that daemon's.
+        let spaceDaemon = windows.spaceCreation(spawn, into: target).daemon ?? services.daemon
         // A person's Cmd-N onto the New Tab page in the window they type in: every key from this
         // moment waits for the new page's field, in order (cx-kql5h), not for the workspace to show.
</file context>

// A person's Cmd-N onto the New Tab page in the window they type in: every key from this
// moment waits for the new page's field, in order (cx-kql5h), not for the workspace to show.
let input = services.keyRouter.newTabInputCoordinator
let opensPage = show && hasOpenWindow && spawn.opensNewTabPage && spawn.command == nil && spawn.firstChat == nil
&& spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? services.daemon)
let keys = opensPage && target == sourceWindow?.state.id ? input.beginIncoming(in: sourceWindow?.window) : nil
&& spawn.firstSession == nil && services.agentTabs.opensFirstPage(on: daemon ?? spaceDaemon)
// A Cloud space's machine still waking would hold the keys for its whole resume: only a connected one holds them.
let keys = opensPage && target == sourceWindow?.state.id && (daemon ?? spaceDaemon).connection != nil
? input.beginIncoming(in: sourceWindow?.window) : nil
spawn.inputToken = keys
// In this turn: the row, and for Cmd-N's page the workspace itself, show before any daemon reply.
let create = BenchSpans.measure("cmdN.start") { windows.startWorkspace(spawn, on: daemon, into: target) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ enum AppActions {
SidebarActionSet(host: services, autoArchive: services.autoArchive, settings: { [weak services] in services?.settings }).bind(into: registry)
NotificationSoundActions(host: services, gate: services.notifications.soundGate).bind(into: registry)
RoomHandlers.bind(into: registry, context: context)
RoomHandlers.bindCloudSpace(into: registry, context: context)
RoomMenuHandlers.bind(into: registry, context: context)
ThemeHandlers.bind(into: registry, context: context)
RemoteLocalhostHandlers.bind(into: registry, context: context)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CmuxNextActions
import CmuxNextCloud
import CmuxNextCloudCredentials
import CmuxNextDaemon
Expand Down Expand Up @@ -539,3 +540,23 @@ final class CloudMachineSession {
}
}
}

extension CloudMachineSession {
/// Wakes this machine for a Cloud space (a paused machine resumes, an ended link connects) and returns once its
/// daemon is connected. A failed resume ends the wait at once with its reason; the bound is `CloudMove`'s.
func connectedForSpace() async throws {
// A machine the person disconnected stays so until they connect it (its menu's Connect): no wait for it.
if disconnected { throw ActionFailure(message: CloudStrings.notConnected) }
// A failure left from an earlier attempt must not end this wait before this attempt's own resume runs.
resumeFailure = nil
if daemon.connection == nil {
await refreshIfMaybePaused()
if machine.status == .paused { attach(origin: .user) } else { connect(origin: .user) }
}
let daemon = daemon
guard await CloudMove.holds(within: .seconds(180), until: { [weak self] in
self?.resumeFailure != nil || daemon.connection != nil
}) else { throw DaemonError.notConnected }
Comment on lines +558 to +559

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A disconnect during this wait leaves connectedForSpace() blocked for the full 180-second timeout because the condition never observes disconnected. Include the terminal disconnect state in the wait and reject it after the wait returns.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineSession.swift, line 555:

<comment>A disconnect during this wait leaves `connectedForSpace()` blocked for the full 180-second timeout because the condition never observes `disconnected`. Include the terminal disconnect state in the wait and reject it after the wait returns.</comment>

<file context>
@@ -536,3 +537,23 @@ final class CloudMachineSession {
+        }
+        let daemon = daemon
+        guard await CloudMove.holds(within: .seconds(180), until: { [weak self] in
+            self?.resumeFailure != nil || daemon.connection != nil
+        }) else { throw DaemonError.notConnected }
+        if let failure = resumeFailure { throw ActionFailure(message: failure) }
</file context>
Suggested change
self?.resumeFailure != nil || daemon.connection != nil
}) else { throw DaemonError.notConnected }
self?.disconnected == true || self?.resumeFailure != nil || daemon.connection != nil
}), !disconnected else { throw ActionFailure(message: CloudStrings.notConnected) }

if let failure = resumeFailure { throw ActionFailure(message: failure) }
}
}
Loading
Loading