Cloud create: Retry sends the creation's own key, to its first send's team
Merged
TE
7d ago+328+328+012345678901234567890123456789-23-23-01234567890123456789
Fixes the duplicate machine a Cloud create's Retry could make on the app-server link (manaflow-ai/cmuxterm-hq#4709).
The bug
CloudMachineCreationkeeps oneCloudMachineRequest, and with it one idempotency key, for the creation's whole life.- The legacy
/api/vmpath already sends that key on every Retry. - The app-server path (
cloud.machine.createthrough the relay) built a freshCloudMachineCreateFlowon every call, and the flow picked a fresh key each time. - So a create that reached the backend but whose answer was lost turned into a second create on Retry. The same happened after a
stillPendingapproval. Either way the result was a second machine and a second charge. - The backend already answers the first machine for a repeated key: CloudDO ledgers each create per caller and key.
The change
appServerCreatesends the request's own key:CloudMachineCreateFlow.create(key:), with a fresh key only when none is given.- The team of the first send is pinned on the request, as the legacy path does. The key is scoped per team, so the same key in another team would be a new machine. A Retry after this Mac's install switched teams is therefore refused with a message, not sent.
- Every relay call, the first and each same-key retry after an approval, checks right before it goes out that the install token still bills the pinned team. A switch during the confirmation or a retry's backoff is refused, nothing is sent, and the request isn't marked sent (
CloudService.pinnedRun). A small window remains between that check and the relay reading its token; pinning the credential itself belongs to #22145's scope work. - A Retry of a request that was already sent skips the local plan gate and goes to the server's lookup of its key, like the legacy path.
- One new localized string covers the refusal.
Tests
CloudCreateRetryKeyTests runs against a fake backend that keeps one machine per (team, key):
- A lost answer, then Retry: one machine, and the same key on both calls.
stillPending, then Retry: every call carries the same key, and there is one machine.- A new create uses a new key and makes a new machine.
- The team rule: an unsent create takes the current team; a sent create stays with its first team and is refused in another.
- The creation keeps its key and first team across
restart(). - A team switch before the first send sends nothing; a switch during approval retries stops them.
No capture: no screen changes. The only visible text is the new refusal message on a rare path (a Retry after the Mac switched teams), which shows in the creation's existing failure row.
Risk
Low. The change is limited to the app-server create path, which is opt-in. The legacy path is unchanged.
Overlap: #22145 (ours) touches CloudService+AppServer.swift for request scope more broadly. This PR is kept separate so the correctness fix isn't held behind it.
Agent session: agent session
š¤ Generated with Claude Code