From 42d8875f3426fb0a8033480ab8f8b4d495e3951e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 04:43:14 -0800 Subject: [PATCH 1/4] test: a person's Cloud create held for approval needs no second prompt Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-548f18c0a6d1ebb48240 --- .../CloudPersonCreateApprovalTests.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift new file mode 100644 index 000000000000..8a222ef0085d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift @@ -0,0 +1,62 @@ +@testable import CmuxNextApp +import CmuxNextCloud +import CmuxNextDaemon +import Foundation +import Testing + +/// A create the person started (the composer's Cloud send, a menu, the palette) is their +/// confirmation: when the backend holds it for the G8 approval, the flow answers that request +/// as the person with no second "Create a Cloud machine?" prompt. A create nobody started still +/// asks first, and a no sends nothing. +@MainActor @Suite(.serialized) struct CloudPersonCreateApprovalTests { + @MainActor final class Calls { + var ops: [String] = [] + var prompts: [CloudMachineCreateFlow.Prompt] = [] + var approved: [String] = [] + var pending = true + var confirms = true + } + + private nonisolated static let machine: JSONValue = .object(["id": .string("vm_00000000000000000009"), "status": .string("provisioning")]) + + private func flow(_ calls: Calls) -> CloudMachineCreateFlow { + CloudMachineCreateFlow( + run: { op, _, _, _ in + await MainActor.run { calls.ops.append(op) } + if await MainActor.run(body: { calls.pending }) { + throw CloudAppOpFailure(code: CloudAppOpFailure.approvalPending, message: "", details: .object(["request": .string("apr_1")])) + } + return .object(["machine": Self.machine]) + }, + confirm: { prompt in calls.prompts.append(prompt); return calls.confirms }, + approve: { request, _, _ in calls.approved.append(request); calls.pending = false }, + pause: { _ in }) + } + + @Test func aPersonsCreateHeldForApprovalIsAnsweredWithNoSecondPrompt() async throws { + let calls = Calls() + let made = try await flow(calls).create(name: nil, startedByPerson: true) + #expect(made == Self.machine) + #expect(calls.prompts.isEmpty, "the person's own click is the confirmation") + #expect(calls.approved == ["apr_1"]) + #expect(calls.ops == ["cloud.machine.create", "cloud.machine.create"]) + } + + @Test func aCreateNobodyStartedAsksOnceThenAnswersTheHold() async throws { + let calls = Calls() + _ = try await flow(calls).create(name: nil, startedByPerson: false, askedBy: "agent-1") + #expect(calls.prompts.count == 1) + if case .agentRequest = calls.prompts.first {} else { Issue.record("expected the agent request prompt, got \(calls.prompts)") } + #expect(calls.approved == ["apr_1"]) + } + + @Test func aDeclinedAgentRequestSendsNothing() async throws { + let calls = Calls() + calls.confirms = false + await #expect(throws: CloudMachineCreateFlow.Failure.declined) { + try await flow(calls).create(name: nil, startedByPerson: false, askedBy: "agent-1") + } + #expect(calls.ops.isEmpty) + #expect(calls.approved.isEmpty) + } +} From 5d7d8d03929cea4b3d9f89253063776a70ec55fa Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 04:43:15 -0800 Subject: [PATCH 2/4] cmux NEXT Cloud: a person's own create answers the approval hold with no second prompt The composer's Cloud send (and every other create a person starts) already is the person's confirmation. On the app-server link the backend holds an install's cloud.machine.create for G8 approval; the flow now answers that request as the person's session without showing "Create a Cloud machine?" again. Creates nobody started (agents, CLI, scripts) still ask first, and a no sends nothing. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-548f18c0a6d1ebb48240 --- .../Cloud/CloudMachineCreateFlow.swift | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift index 5fe51aefed06..413d32e32644 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift @@ -10,14 +10,16 @@ import Foundation /// 2. `cloud.machine.create` goes once with one idempotency key and origin /// `user` (the verified app; the server refuses a money op otherwise). /// 3. The Mac relay calls it with the install token, so the backend answers -/// `approval.pending {request}`. The flow shows the native confirmation -/// if no person clicked in this flow yet; on the click it answers that -/// exact request as the person's session (`feed.answer`), then retries -/// with the SAME key until the op's answer arrives (bounded; the -/// approved op runs in the backend after the answer). +/// `approval.pending {request}`. The flow answers that exact request as +/// the person's session (`feed.answer`), then retries with the SAME key +/// until the op's answer arrives (bounded; the approved op runs in the +/// backend after the answer). /// -/// The flow never answers an approval without a native confirmation click -/// in this flow, and a declined confirmation sends nothing more. +/// The flow never answers an approval without a person's native click in +/// this flow: for a create the person started, that click is their own +/// gesture (the composer's Cloud send, a menu, the palette), so no second +/// confirmation shows; for any other create it is step 1's confirmation. +/// A declined confirmation sends nothing more. @MainActor struct CloudMachineCreateFlow { /// What the person confirms: the exact machine the create asks for. @@ -105,13 +107,15 @@ struct CloudMachineCreateFlow { throw Failure.upgradeRequired(need.reason) } let machine = Self.named(name, askedBy: askedBy) - let confirmed = !startedByPerson - if confirmed { guard await confirm(.agentRequest(machine)) else { throw Failure.declined } } + // A person's own gesture is the confirmation; a create nobody started asks first. Either + // way a person has confirmed before anything is sent, so the backend's hold is answered + // without a second prompt (Leo: pick Cloud, send, "and then it just works"). + if !startedByPerson { guard await confirm(.agentRequest(machine)) else { throw Failure.declined } } var args: [String: JSONValue] = ["size": .object(["cpu": .number(Double(machine.cpu)), "memory_mb": .number(Double(machine.memoryMB)), "disk_mb": .number(Double(machine.diskMB))])] if let name = machine.name { args["name"] = .string(name) } if let environment { args["environment"] = .string(environment) } let op = environment == nil ? "cloud.machine.create" : "cloud.environment.boot" - let answer = try await approvalLoop(op, args: .object(args), key: key ?? newKey(), confirmed: confirmed, + let answer = try await approvalLoop(op, args: .object(args), key: key ?? newKey(), confirmed: true, approved: key == nil ? nil : approved, onApproved: onApproved, answering: key == nil ? nil : answering, onAnswering: onAnswering) { request in await confirm(.approval(request: request, machine)) From fa2c09d3e0368f76dbe6d6ba5a14c3dbaff4cf94 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 04:43:25 -0800 Subject: [PATCH 3/4] trim comments Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-548f18c0a6d1ebb48240 --- .../CmuxNextApp/Cloud/CloudMachineCreateFlow.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift index 413d32e32644..6ce26074d72b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift @@ -15,11 +15,8 @@ import Foundation /// until the op's answer arrives (bounded; the approved op runs in the /// backend after the answer). /// -/// The flow never answers an approval without a person's native click in -/// this flow: for a create the person started, that click is their own -/// gesture (the composer's Cloud send, a menu, the palette), so no second -/// confirmation shows; for any other create it is step 1's confirmation. -/// A declined confirmation sends nothing more. +/// The flow answers an approval only after a person's native click: their +/// own gesture, or step 1's confirmation (a no sends nothing more). @MainActor struct CloudMachineCreateFlow { /// What the person confirms: the exact machine the create asks for. @@ -107,9 +104,7 @@ struct CloudMachineCreateFlow { throw Failure.upgradeRequired(need.reason) } let machine = Self.named(name, askedBy: askedBy) - // A person's own gesture is the confirmation; a create nobody started asks first. Either - // way a person has confirmed before anything is sent, so the backend's hold is answered - // without a second prompt (Leo: pick Cloud, send, "and then it just works"). + // A person's own gesture is the confirmation, so the backend's hold gets no second prompt. if !startedByPerson { guard await confirm(.agentRequest(machine)) else { throw Failure.declined } } var args: [String: JSONValue] = ["size": .object(["cpu": .number(Double(machine.cpu)), "memory_mb": .number(Double(machine.memoryMB)), "disk_mb": .number(Double(machine.diskMB))])] if let name = machine.name { args["name"] = .string(name) } From 81df298450f052a1985ecb14001f2b9cd14ff567 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 05:44:13 -0800 Subject: [PATCH 4/4] Cloud create: a hold answered elsewhere before this Mac answers goes on with the same key Review (gpt-6-astra) on #32270: with the sheet gone, a hold the person answered on another device in the moment before this Mac lists its feed item made approve throw a closed-item mismatch and failed the create. Now the bounded same-key retries read the op's answer; nothing is recorded as answered here, and a hold that stays pending still ends stillPending. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-548f18c0a6d1ebb48240 --- .../Cloud/CloudMachineCreateFlow.swift | 11 +++++++ .../CloudPersonCreateApprovalTests.swift | 33 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift index f0293685d5cb..dbcb5521bd6a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudMachineCreateFlow.swift @@ -158,6 +158,12 @@ struct CloudMachineCreateFlow { where Self.alreadyAnswered(failure, request: request, answeredEarlier: request == answering) { // An earlier attempt answered it and was cancelled before it could record that: the // item is closed, so wait for the approved op like any answered request. + } catch let failure as CloudApprovalAnswer.Failure where Self.itemClosed(failure, request: request) { + // Answered elsewhere (the feed on a phone, the dashboard) before this Mac could: nothing + // was answered here, so it is not recorded; the bounded same-key retries read the op's answer. + approved = request + attempt = 1 + continue } approved = request onApproved(request) @@ -200,6 +206,11 @@ struct CloudMachineCreateFlow { } } + /// The open feed item for `request` is gone (answered elsewhere, or another team's). + static func itemClosed(_ failure: CloudApprovalAnswer.Failure, request: String) -> Bool { + if case .mismatch(request, CloudApprovalAnswer.closedItem) = failure { true } else { false } + } + /// 1, 2, 4 … 8 s between the same-key retries after the approval. static func backoff(_ attempt: Int) -> Duration { .seconds(min(8, 1 << min(max(attempt - 1, 0), 3))) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift index 8a222ef0085d..72a5f94672e6 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudPersonCreateApprovalTests.swift @@ -1,6 +1,7 @@ @testable import CmuxNextApp import CmuxNextCloud import CmuxNextDaemon +import CmuxNextDaemonService import Foundation import Testing @@ -15,6 +16,9 @@ import Testing var approved: [String] = [] var pending = true var confirms = true + /// The hold was answered elsewhere: this Mac finds its feed item closed, and the op goes on (or not). + var closedElsewhere = false + var answersAfterClose = true } private nonisolated static let machine: JSONValue = .object(["id": .string("vm_00000000000000000009"), "status": .string("provisioning")]) @@ -29,7 +33,13 @@ import Testing return .object(["machine": Self.machine]) }, confirm: { prompt in calls.prompts.append(prompt); return calls.confirms }, - approve: { request, _, _ in calls.approved.append(request); calls.pending = false }, + approve: { request, _, _ in + if calls.closedElsewhere { + calls.pending = !calls.answersAfterClose + throw CloudApprovalAnswer.Failure.mismatch(request: request, reason: CloudApprovalAnswer.closedItem) + } + calls.approved.append(request); calls.pending = false + }, pause: { _ in }) } @@ -50,6 +60,27 @@ import Testing #expect(calls.approved == ["apr_1"]) } + @Test func aHoldAnsweredElsewhereFirstGoesOnWithTheSameKey() async throws { + let calls = Calls() + calls.closedElsewhere = true + var recorded: [String] = [] + let made = try await flow(calls).create(name: nil, startedByPerson: true, onApproved: { recorded.append($0) }) + #expect(made == Self.machine) + #expect(calls.prompts.isEmpty) + #expect(recorded.isEmpty, "nothing was answered here, so nothing is recorded as answered") + #expect(calls.ops == ["cloud.machine.create", "cloud.machine.create"]) + } + + @Test func aClosedItemWhoseHoldStaysPendingEndsStillPending() async throws { + let calls = Calls() + calls.closedElsewhere = true + calls.answersAfterClose = false + await #expect(throws: CloudMachineCreateFlow.Failure.stillPending(request: "apr_1")) { + try await flow(calls).create(name: nil, startedByPerson: true) + } + #expect(calls.approved.isEmpty) + } + @Test func aDeclinedAgentRequestSendsNothing() async throws { let calls = Calls() calls.confirms = false