From 3f220d9576e5b6c4bfb7e998dee12d3e36010587 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:26:51 -0700 Subject: [PATCH 1/8] test(cloud): reusing a projection at its current placement changes nothing Reconcile reprojects every missing placement through SurfaceCatalog.project. When the reused pane already carries that placement, attachRemoteView still removes and reinserts it, bumps the projection revision twice, and requests the next reconcile of the same machine. Any disagreement between the plan and project() then becomes a main-actor livelock, which is how nightly b36a9b3 spun at 98% CPU and grew to tens of GB (fixed at the plan level by #16025). Fails on main: projectionVersions advances by 2. Co-Authored-By: Claude Opus 5.5 --- .../CloudWorkspaceLiveProjectionTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index d186677fa040..a52e948ef1c3 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -262,6 +262,30 @@ struct CloudWorkspaceLiveProjectionTests { #expect(catalog.projections == [first.projection]) } + /// Reconcile reprojects through `project`, so a reuse that rewrites unchanged + /// coordinates would request its own next pass and spin the main actor. + @Test("Reusing a projection at its current placement changes nothing") + func reusingCurrentPlacementIsNoOp() async throws { + let fixture = boundWorkspaceFixture() + defer { fixture.workspace.teardownAllPanels() } + let catalog = fixture.catalog + catalog.register(CloudPlacementTestProvider(machine: machine)) + install(try graph(["first": "a"], revision: 1), catalog: catalog) + await fixture.coordinator.waitForIdle() + let projection = try #require(catalog.projections.first { $0.workspaceID == fixture.workspace.id }) + let view = try #require(try catalog.remoteView(for: projection.resource, tabID: projection.remoteTabID, workspaceID: "a")) + let version = catalog.projectionVersions[machine] + + let reused = try await catalog.project( + projection.resource, into: .workspace(id: fixture.workspace.id, placement: .tab), + focus: false, reuseExisting: true, reuseInWorkspace: fixture.workspace.id, remoteView: view + ) + + #expect(reused.reused) + #expect(reused.projection == projection) + #expect(catalog.projectionVersions[machine] == version) + } + @Test("Lifecycle cancellation is not retained as a projection failure") func cancelledMaterializationIsNotAnError() async throws { let live = LiveWorkspaceFixture() From 34cb2cf33979ada6e625549f55ccc42bc80a3ee4 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:27:04 -0700 Subject: [PATCH 2/8] fix(cloud): reattaching a projection's current placement is a no-op attachRemoteView rewrote a reused projection even when its remote workspace and tab were already the requested ones: it removed and reinserted it (clearing and resetting the panel directory, rerunning sidebar git probes, bumping the guest routing revision twice) and requested another reconcile of the machine. Since reconcile itself reprojects through project(), any plan that reports a shown pane as missing became an endless main-actor loop. Return early when the coordinates are unchanged, and apply a real change as one projections assignment so observers never see the pane unprojected. Co-Authored-By: Claude Opus 5.5 --- Sources/Surfaces/SurfaceCatalog.swift | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index acb4e6e8949a..2d35556f02ac 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -1264,23 +1264,20 @@ final class SurfaceCatalog { @discardableResult private func attachRemoteView(_ view: SurfaceRemoteView?, to projection: SurfaceProjection) -> SurfaceProjection { guard let view else { return projection } - if view.isCloudDisplayMembershipView { - guard projection.remoteTabID == nil else { return projection } - projections.remove(projection) - var updated = projection - updated.remoteWorkspaceID = view.workspace.id - updated.remoteTabID = nil - projections.insert(updated) - reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) - notifyChange(for: updated.resource.machine) - return updated - } - guard projection.remoteTabID == nil || projection.remoteTabID == view.tabID else { return projection } - projections.remove(projection) + // A display membership view attaches only to a tabless preview. + let tabID = view.isCloudDisplayMembershipView ? nil : view.tabID + guard projection.remoteTabID == nil || projection.remoteTabID == tabID else { return projection } var updated = projection updated.remoteWorkspaceID = view.workspace.id - updated.remoteTabID = view.tabID - projections.insert(updated) + updated.remoteTabID = tabID + // Reconcile reprojects through here. Rewriting unchanged coordinates + // would request the next reconcile of this machine, forever. + guard updated != projection else { return projection } + // One assignment, so observers never see the pane briefly unprojected. + var next = projections + next.remove(projection) + next.insert(updated) + projections = next reconcileCloudWorkspaceBinding(localWorkspaceID: updated.workspaceID) notifyChange(for: updated.resource.machine) return updated From 4f14ae85a29b44ed0dd1387cce7575ef8c1527e0 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:49:24 -0700 Subject: [PATCH 3/8] fix(cloud): setting a projection's current remote placement is a no-op Same guard as attachRemoteView for setRemotePlacement: skip views whose coordinates already match, and apply real changes as one projections assignment. Unchanged placements no longer bump the projection revision or post a catalog change that wakes the device layout coordinator. The test now states its fixture precondition explicitly. Co-Authored-By: Claude Opus 5.5 --- Sources/Surfaces/SurfaceCatalog.swift | 9 +++++++-- cmuxTests/CloudWorkspaceLiveProjectionTests.swift | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 2d35556f02ac..0c85c4102b58 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -1245,16 +1245,21 @@ final class SurfaceCatalog { } func setRemotePlacement(for source: SurfaceProjection, workspaceID: String?, tabID: String?) { + // Only views whose coordinates change; an unchanged placement notifies nobody. let matching = projections.filter { $0.resource == source.resource && ($0.panelID == source.panelID || (tabID != nil && $0.remoteTabID == tabID)) + && ($0.remoteWorkspaceID != workspaceID || $0.remoteTabID != tabID) } + guard !matching.isEmpty else { return } + var next = projections for var projection in matching { - projections.remove(projection) + next.remove(projection) projection.remoteWorkspaceID = workspaceID projection.remoteTabID = tabID - projections.insert(projection) + next.insert(projection) } + projections = next notifyChange(for: source.resource.machine) } diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index a52e948ef1c3..847fcde85729 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -273,6 +273,7 @@ struct CloudWorkspaceLiveProjectionTests { install(try graph(["first": "a"], revision: 1), catalog: catalog) await fixture.coordinator.waitForIdle() let projection = try #require(catalog.projections.first { $0.workspaceID == fixture.workspace.id }) + #expect(projection.remoteWorkspaceID == "a" && projection.remoteTabID == "first") let view = try #require(try catalog.remoteView(for: projection.resource, tabID: projection.remoteTabID, workspaceID: "a")) let version = catalog.projectionVersions[machine] From 2307c498c64d5b2ce9104a8f6afb208b8c9ed640 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:30:55 -0700 Subject: [PATCH 4/8] test(cloud): reconciling one graph stops when every pass requests another A consumer that requests another reconcile without changing the accepted graph keeps CloudWorkspaceProjectionCoordinator's loop running forever on the main actor, which is how nightly b36a9b3 hung at 100% CPU and grew to tens of GB. Fails on main: the loop runs until the test stub stops asking (1000 passes). Co-Authored-By: Claude Opus 5.5 --- .../CloudWorkspaceLiveProjectionTests.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index 847fcde85729..2efcce46d6de 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -287,6 +287,29 @@ struct CloudWorkspaceLiveProjectionTests { #expect(catalog.projectionVersions[machine] == version) } + /// Any consumer that requests another pass without changing the graph (the + /// nightly b36a9b3 livelock) must end in a bounded number of passes. + @Test("Reconciling one graph stops when every pass requests another") + func reconcileOfOneGraphIsBounded() async throws { + let fixture = boundWorkspaceFixture() + defer { fixture.workspace.teardownAllPanels() } + let catalog = fixture.catalog + let coordinator = fixture.coordinator + let machine = self.machine + var passes = 0 + coordinator.environment.applyLayout = { [unowned catalog, unowned coordinator] _, _, _ in + passes += 1 + if passes < 1_000 { coordinator.request(machine: machine, catalog: catalog) } + } + catalog.register(CloudPlacementTestProvider(machine: machine)) + install(try graph(["first": "a"], revision: 1), catalog: catalog) + await coordinator.waitForIdle() + + #expect(passes > 0, "The fixture must reach the layout step") + #expect(passes <= 8) + #expect(catalog.projections.contains { $0.workspaceID == fixture.workspace.id && $0.remoteTabID == "first" }) + } + @Test("Lifecycle cancellation is not retained as a projection failure") func cancelledMaterializationIsNotAnError() async throws { let live = LiveWorkspaceFixture() From aaf9f68a271a6d2346e6374640dde66070591349 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:31:15 -0700 Subject: [PATCH 5/8] fix(cloud): bound reconciliation passes over one accepted graph CloudWorkspaceProjectionCoordinator re-ran reconcile while anything kept requesting it, with no progress check. Any consumer that asks for another pass without changing the graph (attachRemoteView before this PR, a plan that reports a shown pane as missing in #16025) held the main actor forever: nightly b36a9b3 pinned a core, grew to tens of GB, and could not even run its updater. Count passes over the same accepted CloudVMState. A converging graph needs two or three; after eight, stop, report a Sentry warning, and wait for the next graph or request, which starts a new count. Co-Authored-By: Claude Opus 5.5 --- .../CloudWorkspaceProjectionCoordinator.swift | 24 +++++++++++++++++++ .../CloudWorkspaceLiveProjectionTests.swift | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index 7b80ceb29185..91e5ef65a4bc 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -43,6 +43,8 @@ final class CloudWorkspaceProjectionCoordinator { guard let self else { return } defer { if self.tasks[machine]?.id == id { self.tasks[machine] = nil } } guard let catalog else { return } + var reconciled: CloudVMState? + var passes = 0 while self.requested.remove(machine) != nil { guard !Task.isCancelled, self.localMutations[machine] == nil else { return } await catalog.cloudPlacementCoordinator.waitForPendingMutations() @@ -51,12 +53,34 @@ final class CloudWorkspaceProjectionCoordinator { let state = catalog.cloudStates[machine], catalog.cloudStateObservations[machine]?.freshness == .current, catalog.cloudPlacementCoordinator.allowsNativeReconciliation(state) else { return } + if reconciled == state { passes += 1 } else { reconciled = state; passes = 1 } + guard passes <= Self.maxPassesPerState else { + Self.reportNonConvergence(machine: machine, passes: passes) + return + } await self.reconcile(state: state, catalog: catalog) } } tasks[machine] = CloudWorkspaceProjectionTask(id: id, task: task) } + /// One accepted graph reaches its fixed point in a few passes: materialize, + /// then confirm. More passes over an unchanged graph mean some consumer + /// requests reconciliation without progress, which would otherwise hold + /// the main actor forever. A new graph or a later request starts a new count. + static let maxPassesPerState = 8 + + private static func reportNonConvergence(machine: SurfaceMachineID, passes: Int) { +#if DEBUG + cmuxDebugLog("cloudWorkspace.projection.nonConvergent machine=\(machine.rawValue) passes=\(passes)") +#endif + sentryCaptureWarning( + "Cloud workspace projection did not converge", + category: "cloud.projection", + data: ["passes": passes] + ) + } + /// A bound mirror may not recreate a view that the accepted graph removed. /// Unbound viewers retain their existing attachment-repair behavior. func retainsProjection(_ projection: SurfaceProjection, in state: CloudVMState) -> Bool { diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index 2efcce46d6de..62c1094e2502 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -306,7 +306,7 @@ struct CloudWorkspaceLiveProjectionTests { await coordinator.waitForIdle() #expect(passes > 0, "The fixture must reach the layout step") - #expect(passes <= 8) + #expect(passes <= CloudWorkspaceProjectionCoordinator.maxPassesPerState) #expect(catalog.projections.contains { $0.workspaceID == fixture.workspace.id && $0.remoteTabID == "first" }) } From d7a5cb71378607b5743e65642c4930863ed82e34 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:37:27 -0700 Subject: [PATCH 6/8] fix(cloud): bound reconciliation by progress, not by passes over one graph Review of the previous bound: counting every pass over an unchanged graph could stop a reconcile that was still making progress (a staggered restore of several bound workspaces re-requests the same graph), stranding panes until the next graph. CloudWorkspaceReconcileBudget now stops after three consecutive passes that start from the same graph, projection revision and bindings (a pass that changed nothing cannot make the next one different), with a hard ceiling of 64 passes per graph for a loop that rewrites projections every pass, as nightly b36a9b3 did. Non-convergence is reported once per graph. Co-Authored-By: Claude Opus 5.5 --- .../CloudWorkspaceProjectionCoordinator.swift | 58 ++++++++++++++----- .../CloudWorkspaceLiveProjectionTests.swift | 37 +++++++++++- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index 91e5ef65a4bc..215fca568c3c 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -43,8 +43,7 @@ final class CloudWorkspaceProjectionCoordinator { guard let self else { return } defer { if self.tasks[machine]?.id == id { self.tasks[machine] = nil } } guard let catalog else { return } - var reconciled: CloudVMState? - var passes = 0 + var budget = CloudWorkspaceReconcileBudget() while self.requested.remove(machine) != nil { guard !Task.isCancelled, self.localMutations[machine] == nil else { return } await catalog.cloudPlacementCoordinator.waitForPendingMutations() @@ -53,9 +52,13 @@ final class CloudWorkspaceProjectionCoordinator { let state = catalog.cloudStates[machine], catalog.cloudStateObservations[machine]?.freshness == .current, catalog.cloudPlacementCoordinator.allowsNativeReconciliation(state) else { return } - if reconciled == state { passes += 1 } else { reconciled = state; passes = 1 } - guard passes <= Self.maxPassesPerState else { - Self.reportNonConvergence(machine: machine, passes: passes) + let progress = CloudWorkspaceReconcileBudget.Mark( + state: state, + projectionVersion: catalog.projectionVersions[machine, default: 0], + bindings: self.environment.bindings().filter { $0.value.vmID == machine.rawValue } + ) + guard budget.admit(progress) else { + self.reportNonConvergence(machine: machine, state: state, budget: budget) return } await self.reconcile(state: state, catalog: catalog) @@ -64,20 +67,20 @@ final class CloudWorkspaceProjectionCoordinator { tasks[machine] = CloudWorkspaceProjectionTask(id: id, task: task) } - /// One accepted graph reaches its fixed point in a few passes: materialize, - /// then confirm. More passes over an unchanged graph mean some consumer - /// requests reconciliation without progress, which would otherwise hold - /// the main actor forever. A new graph or a later request starts a new count. - static let maxPassesPerState = 8 + private var reportedNonConvergence: [SurfaceMachineID: CloudVMState] = [:] - private static func reportNonConvergence(machine: SurfaceMachineID, passes: Int) { + /// Reports each non-converging graph once, so a trigger outside this loop + /// that keeps restarting it cannot flood crash reporting. + private func reportNonConvergence(machine: SurfaceMachineID, state: CloudVMState, budget: CloudWorkspaceReconcileBudget) { #if DEBUG - cmuxDebugLog("cloudWorkspace.projection.nonConvergent machine=\(machine.rawValue) passes=\(passes)") + cmuxDebugLog("cloudWorkspace.projection.nonConvergent machine=\(machine.rawValue) passes=\(budget.passes) idle=\(budget.idlePasses)") #endif + guard reportedNonConvergence[machine] != state else { return } + reportedNonConvergence[machine] = state sentryCaptureWarning( "Cloud workspace projection did not converge", category: "cloud.projection", - data: ["passes": passes] + data: ["passes": budget.passes, "idlePasses": budget.idlePasses] ) } @@ -194,3 +197,32 @@ final class CloudWorkspaceProjectionCoordinator { failures = failures.filter { live.contains($0.key) } } } + +/// Bounds reconciliation of one accepted graph. A pass that changes nothing +/// (same graph, projections and bindings as the pass before) cannot make the +/// next one different, so a few in a row mean a consumer is requesting passes +/// without progress. The hard ceiling also stops a loop that rewrites the same +/// projections every pass, which looks like progress. +struct CloudWorkspaceReconcileBudget { + struct Mark: Equatable { + let state: CloudVMState + let projectionVersion: UInt64 + let bindings: [UUID: WorkspaceCloudVMBinding] + } + + static let maxIdlePasses = 3 + static let maxPassesPerState = 64 + + private var last: Mark? + private(set) var passes = 0 + private(set) var idlePasses = 0 + + /// Records the catalog before a pass; false means stop reconciling this graph. + mutating func admit(_ mark: Mark) -> Bool { + if last?.state != mark.state { passes = 0; idlePasses = 0 } + idlePasses = last == mark ? idlePasses + 1 : 0 + passes += 1 + last = mark + return idlePasses < Self.maxIdlePasses && passes <= Self.maxPassesPerState + } +} diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index 62c1094e2502..0a12b48111bc 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -306,10 +306,45 @@ struct CloudWorkspaceLiveProjectionTests { await coordinator.waitForIdle() #expect(passes > 0, "The fixture must reach the layout step") - #expect(passes <= CloudWorkspaceProjectionCoordinator.maxPassesPerState) + #expect(passes <= CloudWorkspaceReconcileBudget.maxIdlePasses + 2) #expect(catalog.projections.contains { $0.workspaceID == fixture.workspace.id && $0.remoteTabID == "first" }) } + /// The nightly b36a9b3 shape: every pass rewrote the same projection, which + /// advances the projection revision and so looks like progress. + @Test("Reconciling one graph stops when every pass rewrites projections and requests another") + func reconcileThatOnlyLooksBusyIsBounded() async throws { + let fixture = boundWorkspaceFixture() + defer { fixture.workspace.teardownAllPanels() } + let catalog = fixture.catalog + let coordinator = fixture.coordinator + let machine = self.machine + var passes = 0 + coordinator.environment.applyLayout = { [unowned catalog, unowned coordinator] _, _, _ in + passes += 1 + catalog.projectionVersions[machine, default: 0] &+= 1 + if passes < 1_000 { coordinator.request(machine: machine, catalog: catalog) } + } + catalog.register(CloudPlacementTestProvider(machine: machine)) + install(try graph(["first": "a"], revision: 1), catalog: catalog) + await coordinator.waitForIdle() + + #expect(passes > 0, "The fixture must reach the layout step") + #expect(passes <= CloudWorkspaceReconcileBudget.maxPassesPerState) + } + + @Test("A reconcile that keeps making progress is not cut short by the idle limit") + func progressingPassesStayAdmitted() throws { + let state = try graph(["first": "a"], revision: 1) + var budget = CloudWorkspaceReconcileBudget() + for version in 0.. Date: Wed, 30 Sep 2026 12:45:38 -0700 Subject: [PATCH 7/8] fix(cloud): report projection non-convergence once per daemon generation Review: keying the dedupe on the full CloudVMState retained a whole graph per machine for the process lifetime (cancel never cleared it) and still reported once per revision. Key on the cursor generation, include generation and revision in the event, and clear it when the machine is cancelled. Co-Authored-By: Claude Opus 5.5 --- .../CloudWorkspaceProjectionCoordinator.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index 215fca568c3c..e3b64547672f 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -67,20 +67,26 @@ final class CloudWorkspaceProjectionCoordinator { tasks[machine] = CloudWorkspaceProjectionTask(id: id, task: task) } - private var reportedNonConvergence: [SurfaceMachineID: CloudVMState] = [:] + /// Daemon generation last reported per machine. A persistent re-requester + /// would otherwise report once for every graph revision. + private var reportedNonConvergence: [SurfaceMachineID: String] = [:] - /// Reports each non-converging graph once, so a trigger outside this loop - /// that keeps restarting it cannot flood crash reporting. + /// Reports non-convergence once per daemon generation, so a trigger outside + /// this loop that keeps restarting it cannot flood crash reporting. private func reportNonConvergence(machine: SurfaceMachineID, state: CloudVMState, budget: CloudWorkspaceReconcileBudget) { #if DEBUG cmuxDebugLog("cloudWorkspace.projection.nonConvergent machine=\(machine.rawValue) passes=\(budget.passes) idle=\(budget.idlePasses)") #endif - guard reportedNonConvergence[machine] != state else { return } - reportedNonConvergence[machine] = state + let generation = state.cursor?.generation ?? "" + guard reportedNonConvergence[machine] != generation else { return } + reportedNonConvergence[machine] = generation sentryCaptureWarning( "Cloud workspace projection did not converge", category: "cloud.projection", - data: ["passes": budget.passes, "idlePasses": budget.idlePasses] + data: [ + "passes": budget.passes, "idlePasses": budget.idlePasses, + "generation": generation, "revision": state.cursor?.revision ?? 0, + ] ) } @@ -123,6 +129,7 @@ final class CloudWorkspaceProjectionCoordinator { tasks.removeValue(forKey: machine)?.task.cancel() requested.remove(machine) localMutations[machine] = nil + reportedNonConvergence[machine] = nil let bindings = environment.bindings() failures = failures.filter { bindings[$0.key]?.vmID != machine.rawValue } } From 943643722e961991ccf80267b63ff0465adaeb3b Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:00:07 -0700 Subject: [PATCH 8/8] chore(l10n): document the French Actions discovery titles as invariant Same change as #16175: main's localization parity check fails on actions.discovery.menuTitle and dialogTitle (fr is identical to English), which blocks this PR's static preflight and every gate behind it. Co-Authored-By: Claude Opus 5.5 --- scripts/localization-allowed-omissions.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index fb157c618c4d..c2321f566ae9 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -3514,5 +3514,17 @@ "ja": "TextBox is the feature name; this locale already kept it untranslated before the beta suffix was removed.", "zh-Hans": "TextBox is the feature name; this locale already kept it untranslated before the beta suffix was removed." } + }, + "actions.discovery.menuTitle": { + "source": "Actions · cmux.json…", + "identityLocales": { + "fr": "\"Actions\" is spelled the same in French; cmux.json is a file name." + } + }, + "actions.discovery.dialogTitle": { + "source": "Actions · cmux.json", + "identityLocales": { + "fr": "\"Actions\" is spelled the same in French; cmux.json is a file name." + } } }