From a74a3916f66ede2e8f89b8bfa04df55faca1248c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:24:43 -0700 Subject: [PATCH 1/7] fix: share OpenCodePaths with the CLI through CMUXAgentLaunch #16229 made CLI/cmux.swift call OpenCodePaths, but the enum lived in Sources/SessionIndexModels.swift, which only the app target compiles, so the CLI target fails with "cannot find 'OpenCodePaths' in scope". Move the unchanged path logic into CMUXAgentLaunch, which the app, the CLI and cmuxTests already import, and make its two entry points public. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CMUXAgentLaunch/OpenCodePaths.swift | 50 +++++++++++++++++++ Sources/SessionIndexModels.swift | 49 ------------------ cmuxTests/OpenCodeHookRegressionTests.swift | 1 + 3 files changed, 51 insertions(+), 49 deletions(-) create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift new file mode 100644 index 000000000000..83159ac0a87e --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Resolves the filesystem locations used by OpenCode from its documented +/// environment overrides. +public enum OpenCodePaths { + public static func configDirectory(environment: [String: String]) -> URL { + let home = homeURL(environment: environment) + if let override = nonEmpty(environment["OPENCODE_CONFIG_DIR"]) { + return expandedURL(override, home: home) + } + if let xdgConfigHome = nonEmpty(environment["XDG_CONFIG_HOME"]) { + return expandedURL(xdgConfigHome, home: home) + .appendingPathComponent("opencode", isDirectory: true) + } + return home.appendingPathComponent(".config/opencode", isDirectory: true) + } + + public static func databaseURL(environment: [String: String]) -> URL { + let home = homeURL(environment: environment) + if let override = nonEmpty(environment["OPENCODE_DB"]) { + return expandedURL(override, home: home) + } + if let xdgDataHome = nonEmpty(environment["XDG_DATA_HOME"]) { + return expandedURL(xdgDataHome, home: home) + .appendingPathComponent("opencode/opencode.db", isDirectory: false) + } + return home.appendingPathComponent(".local/share/opencode/opencode.db", isDirectory: false) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func expandedURL(_ path: String, home: URL) -> URL { + if path == "~" { return home } + if path.hasPrefix("~/") { + return home.appendingPathComponent(String(path.dropFirst(2)), isDirectory: false) + } + return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) + } + + private static func homeURL(environment: [String: String]) -> URL { + if let home = nonEmpty(environment["HOME"]) { + return URL(fileURLWithPath: NSString(string: home).expandingTildeInPath) + } + return FileManager.default.homeDirectoryForCurrentUser + } +} diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index ef6141c8aa55..ce92b4f3482d 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -1,55 +1,6 @@ import CMUXAgentLaunch import Foundation -/// Resolves the filesystem locations used by OpenCode from its documented -/// environment overrides. -enum OpenCodePaths { - static func configDirectory(environment: [String: String]) -> URL { - let home = homeURL(environment: environment) - if let override = nonEmpty(environment["OPENCODE_CONFIG_DIR"]) { - return expandedURL(override, home: home) - } - if let xdgConfigHome = nonEmpty(environment["XDG_CONFIG_HOME"]) { - return expandedURL(xdgConfigHome, home: home) - .appendingPathComponent("opencode", isDirectory: true) - } - return home.appendingPathComponent(".config/opencode", isDirectory: true) - } - - static func databaseURL(environment: [String: String]) -> URL { - let home = homeURL(environment: environment) - if let override = nonEmpty(environment["OPENCODE_DB"]) { - return expandedURL(override, home: home) - } - if let xdgDataHome = nonEmpty(environment["XDG_DATA_HOME"]) { - return expandedURL(xdgDataHome, home: home) - .appendingPathComponent("opencode/opencode.db", isDirectory: false) - } - return home.appendingPathComponent(".local/share/opencode/opencode.db", isDirectory: false) - } - - private static func nonEmpty(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private static func expandedURL(_ path: String, home: URL) -> URL { - if path == "~" { return home } - if path.hasPrefix("~/") { - return home.appendingPathComponent(String(path.dropFirst(2)), isDirectory: false) - } - return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) - } - - private static func homeURL(environment: [String: String]) -> URL { - if let home = nonEmpty(environment["HOME"]) { - return URL(fileURLWithPath: NSString(string: home).expandingTildeInPath) - } - return FileManager.default.homeDirectoryForCurrentUser - } -} - // MARK: - Agents struct RegisteredSessionAgent: Hashable, Sendable { diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index 29edce73249c..e09f15de2d41 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -1,3 +1,4 @@ +import CMUXAgentLaunch import XCTest import Darwin From f26bc0ef0393d48d826f387c3763ca2e106383a6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:41:33 -0700 Subject: [PATCH 2/7] Pass the temporary-config flag to the Codex provider override parser #16201 made providerOverrides(from:) skip provider entries when the caller uses a temporary CODEX_HOME, but read `usesTemporaryConfig`, a parameter of build(configToml:usesTemporaryConfig:) that is not in scope there, so the CLI no longer compiles. Pass the flag through. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+AutoNaming.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLI/CMUXCLI+AutoNaming.swift b/CLI/CMUXCLI+AutoNaming.swift index ebcdd9be93b9..3d5442cf7d8e 100644 --- a/CLI/CMUXCLI+AutoNaming.swift +++ b/CLI/CMUXCLI+AutoNaming.swift @@ -232,14 +232,14 @@ struct CodexAutoNamingArguments: Sendable { arguments.insert("--ignore-user-config", at: arguments.firstIndex(of: "--ignore-rules")!) } guard let configToml else { return arguments } - let overrides = providerOverrides(from: configToml) + let overrides = providerOverrides(from: configToml, usesTemporaryConfig: usesTemporaryConfig) for override in overrides.reversed() { arguments.insert(contentsOf: ["-c", override], at: 1) } return arguments } - private static func providerOverrides(from toml: String) -> [String] { + private static func providerOverrides(from toml: String, usesTemporaryConfig: Bool) -> [String] { var model: String? var modelProvider: String? var providerEntries: [(section: String, key: String, value: String)] = [] From 4e168256c5cf449e54689080ccdf0151f30b0ebd Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 18:16:02 -0400 Subject: [PATCH 3/7] test: match temporary Codex config argument scope --- cmuxCLITests/CodexAutoNamingArgumentsTests.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmuxCLITests/CodexAutoNamingArgumentsTests.swift b/cmuxCLITests/CodexAutoNamingArgumentsTests.swift index e47dcbe9efc7..fc678561a288 100644 --- a/cmuxCLITests/CodexAutoNamingArgumentsTests.swift +++ b/cmuxCLITests/CodexAutoNamingArgumentsTests.swift @@ -41,7 +41,10 @@ struct CodexAutoNamingArgumentsTests { let overrides = configOverrides(args) #expect(overrides.contains("model_provider=\"subrouter\"")) #expect(overrides.contains("model=\"gpt-5-codex\"")) - #expect(overrides.contains("model_providers.subrouter.base_url=\"http://127.0.0.1:31415/v1\"")) + // With a temporary CODEX_HOME, the provider definition is already + // available in its mode-restricted config.toml. Do not duplicate it + // on argv, where nested provider values could expose credentials. + #expect(!overrides.contains("model_providers.subrouter.base_url=\"http://127.0.0.1:31415/v1\"")) #expect(!overrides.contains(where: { $0.contains("secret") || $0.contains("experimental_bearer_token") || $0.contains("api-secret") })) From 9b8bcd4b838d4f324c94dc44139c2e97fb42c859 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:57:10 -0700 Subject: [PATCH 4/7] Make OpenCodePaths a value type to satisfy package conventions Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/cmux.swift | 2 +- .../CMUXAgentLaunch/OpenCodePaths.swift | 44 ++++++++++--------- Sources/SessionIndexModels.swift | 2 +- cmuxTests/OpenCodeHookRegressionTests.swift | 12 ++--- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f240a6a4a268..d5196a2a5b3e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -40474,7 +40474,7 @@ export default { private static let openCodePluginFileName = "cmux-feed.js" private func openCodeConfigDirPath() -> String { - OpenCodePaths.configDirectory(environment: ProcessInfo.processInfo.environment).path + OpenCodePaths(environment: ProcessInfo.processInfo.environment).configDirectory.path } private func openCodePluginPath(projectLocal: Bool) -> String { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift index 83159ac0a87e..ec86aa8dc5db 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift @@ -1,30 +1,34 @@ import Foundation -/// Resolves the filesystem locations used by OpenCode from its documented +/// The filesystem locations used by OpenCode, resolved from its documented /// environment overrides. -public enum OpenCodePaths { - public static func configDirectory(environment: [String: String]) -> URL { - let home = homeURL(environment: environment) - if let override = nonEmpty(environment["OPENCODE_CONFIG_DIR"]) { - return expandedURL(override, home: home) - } - if let xdgConfigHome = nonEmpty(environment["XDG_CONFIG_HOME"]) { - return expandedURL(xdgConfigHome, home: home) - .appendingPathComponent("opencode", isDirectory: true) - } - return home.appendingPathComponent(".config/opencode", isDirectory: true) - } +public struct OpenCodePaths: Sendable, Equatable { + /// OpenCode's configuration directory: `OPENCODE_CONFIG_DIR`, then + /// `$XDG_CONFIG_HOME/opencode`, then `~/.config/opencode`. + public let configDirectory: URL + /// OpenCode's session database: `OPENCODE_DB`, then + /// `$XDG_DATA_HOME/opencode/opencode.db`, then + /// `~/.local/share/opencode/opencode.db`. + public let databaseURL: URL - public static func databaseURL(environment: [String: String]) -> URL { - let home = homeURL(environment: environment) - if let override = nonEmpty(environment["OPENCODE_DB"]) { - return expandedURL(override, home: home) + public init(environment: [String: String]) { + let home = Self.homeURL(environment: environment) + if let override = Self.nonEmpty(environment["OPENCODE_CONFIG_DIR"]) { + configDirectory = Self.expandedURL(override, home: home) + } else if let xdgConfigHome = Self.nonEmpty(environment["XDG_CONFIG_HOME"]) { + configDirectory = Self.expandedURL(xdgConfigHome, home: home) + .appendingPathComponent("opencode", isDirectory: true) + } else { + configDirectory = home.appendingPathComponent(".config/opencode", isDirectory: true) } - if let xdgDataHome = nonEmpty(environment["XDG_DATA_HOME"]) { - return expandedURL(xdgDataHome, home: home) + if let override = Self.nonEmpty(environment["OPENCODE_DB"]) { + databaseURL = Self.expandedURL(override, home: home) + } else if let xdgDataHome = Self.nonEmpty(environment["XDG_DATA_HOME"]) { + databaseURL = Self.expandedURL(xdgDataHome, home: home) .appendingPathComponent("opencode/opencode.db", isDirectory: false) + } else { + databaseURL = home.appendingPathComponent(".local/share/opencode/opencode.db", isDirectory: false) } - return home.appendingPathComponent(".local/share/opencode/opencode.db", isDirectory: false) } private static func nonEmpty(_ value: String?) -> String? { diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index ce92b4f3482d..3dd7a76e829d 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -151,7 +151,7 @@ enum OpenCodeDatabaseSnapshot { static func make(prefix: String) throws -> Snapshot? { let fileManager = FileManager.default - let sourceURL = OpenCodePaths.databaseURL(environment: ProcessInfo.processInfo.environment) + let sourceURL = OpenCodePaths(environment: ProcessInfo.processInfo.environment).databaseURL guard fileManager.fileExists(atPath: sourceURL.path) else { return nil } let snapshotDir = fileManager.temporaryDirectory.appendingPathComponent( diff --git a/cmuxTests/OpenCodeHookRegressionTests.swift b/cmuxTests/OpenCodeHookRegressionTests.swift index e09f15de2d41..5182ff13187e 100644 --- a/cmuxTests/OpenCodeHookRegressionTests.swift +++ b/cmuxTests/OpenCodeHookRegressionTests.swift @@ -12,8 +12,8 @@ final class OpenCodeHookRegressionTests: XCTestCase { "OPENCODE_DB": "~/custom.sqlite" ] - XCTAssertEqual(OpenCodePaths.configDirectory(environment: environment).path, "/tmp/home/custom-config") - XCTAssertEqual(OpenCodePaths.databaseURL(environment: environment).path, "/tmp/home/custom.sqlite") + XCTAssertEqual(OpenCodePaths(environment: environment).configDirectory.path, "/tmp/home/custom-config") + XCTAssertEqual(OpenCodePaths(environment: environment).databaseURL.path, "/tmp/home/custom.sqlite") } func testOpenCodePathResolutionUsesXDGLocations() { @@ -23,15 +23,15 @@ final class OpenCodeHookRegressionTests: XCTestCase { "XDG_DATA_HOME": "~/xdg-data" ] - XCTAssertEqual(OpenCodePaths.configDirectory(environment: environment).path, "/tmp/home/xdg-config/opencode") - XCTAssertEqual(OpenCodePaths.databaseURL(environment: environment).path, "/tmp/home/xdg-data/opencode/opencode.db") + XCTAssertEqual(OpenCodePaths(environment: environment).configDirectory.path, "/tmp/home/xdg-config/opencode") + XCTAssertEqual(OpenCodePaths(environment: environment).databaseURL.path, "/tmp/home/xdg-data/opencode/opencode.db") } func testOpenCodePathResolutionKeepsLegacyDefaults() { let environment = ["HOME": "/tmp/home"] - XCTAssertEqual(OpenCodePaths.configDirectory(environment: environment).path, "/tmp/home/.config/opencode") - XCTAssertEqual(OpenCodePaths.databaseURL(environment: environment).path, "/tmp/home/.local/share/opencode/opencode.db") + XCTAssertEqual(OpenCodePaths(environment: environment).configDirectory.path, "/tmp/home/.config/opencode") + XCTAssertEqual(OpenCodePaths(environment: environment).databaseURL.path, "/tmp/home/.local/share/opencode/opencode.db") } private struct ProcessRunResult { From 9a246c6e574d2684d485ddc0c199fc7e086ca3e5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:50:52 -0700 Subject: [PATCH 5/7] ci: cmux-tui artifact publishing runs in its own artifacts environment (#16267) * test(ci): cmux-tui artifact publishing must run in the artifacts environment #16171 put the cmux-tui publish job in the release environment, whose policy allows only main and v* tags, so helper-branch pin publishes fail before any step runs. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(ci): cmux-tui artifact publishing runs in the artifacts environment The artifacts environment holds only the R2 upload credentials and allows main, feat-cmux-next and cmux-tui-pin-* helper branches, so daemon pin publishes work again while signing, Sparkle, Homebrew and Apple secrets stay in release (main and v* tags only). Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/cmux-tui-artifacts.yml | 9 +++++--- ...est_ci_production_secrets_protected_env.py | 21 +++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cmux-tui-artifacts.yml b/.github/workflows/cmux-tui-artifacts.yml index df3db9a7c632..4e1ac62a93f3 100644 --- a/.github/workflows/cmux-tui-artifacts.yml +++ b/.github/workflows/cmux-tui-artifacts.yml @@ -89,9 +89,12 @@ jobs: macos_runner: ${{ inputs.macos_runner || vars.MACOS_RUNNER_BACKGROUND || 'macos-15' }} publish: - # Production secrets: GitHub releases them only to protected refs (the - # environment's deployment branch policy: main, tags v*). - environment: release + # R2 upload credentials only. A commit-addressed cmux-tui build is not a + # production release: cmux-next pins daemon builds from helper branches. + # The artifacts environment's deployment branch policy allows main, + # feat-cmux-next and cmux-tui-pin-*; signing, Sparkle, Homebrew and Apple + # secrets stay in the release environment. + environment: artifacts name: publish to R2 # PR runs of this workflow only validate the build; publishing is # main-push or manual dispatch. diff --git a/tests/test_ci_production_secrets_protected_env.py b/tests/test_ci_production_secrets_protected_env.py index ba7d6865788f..d633efc8c7d7 100755 --- a/tests/test_ci_production_secrets_protected_env.py +++ b/tests/test_ci_production_secrets_protected_env.py @@ -5,7 +5,7 @@ ref the dispatcher picks, so any condition written in the workflow can be edited away on a branch. GitHub enforces an environment's deployment branch policy outside the workflow: these jobs declare the `release` environment -(policy: branch main, tags v*), or a cloud-vm environment with its own +(policy: branch main, tags v*), the artifacts environment, or a cloud-vm environment with its own policy, and their production secrets live in that environment. The iroh release gate checks out a requested ref; its job with production @@ -13,6 +13,7 @@ """ import os +import re import sys import yaml @@ -21,6 +22,15 @@ WORKFLOWS = os.path.join(ROOT, ".github", "workflows") FAILURES = [] +# Publishing a commit-addressed cmux-tui build is not a production release: +# cmux-next pins daemon builds from helper branches (cmux-tui-pin-*). That job +# runs in the `artifacts` environment (policy: main, feat-cmux-next, +# cmux-tui-pin-*), which holds only the R2 upload credentials. +ARTIFACT_JOBS = { + "cmux-tui-artifacts.yml": ["publish"], +} +ARTIFACT_SECRETS = {"CF_R2_ACCESS_KEY_ID", "CF_R2_SECRET_ACCESS_KEY", "CF_R2_ACCOUNT_ID"} + RELEASE_JOBS = { "release.yml": ["build-sign-notarize"], "nightly.yml": ["build-sign-notarize-nightly", "publish-nightly"], @@ -29,7 +39,6 @@ "ios-appstore-upload.yml": ["set-testflight-notes", "assign-internal"], "repair-v0-64-25-helper-rpaths.yml": ["repair"], "iroh-release-gate.yml": ["simulator-e2e"], - "cmux-tui-artifacts.yml": ["publish"], "repair-nightly-appcast-content-types.yml": ["repair"], "update-homebrew.yml": ["update-cask"], } @@ -49,6 +58,14 @@ def main(): for job in jobs: definition = document["jobs"].get(job, {}) _check(definition.get("environment") == "release", f"{name} {job} runs in the release environment") + for name, jobs in ARTIFACT_JOBS.items(): + text = open(os.path.join(WORKFLOWS, name), encoding="utf-8").read() + document = yaml.load(text, Loader=yaml.BaseLoader) + for job in jobs: + definition = document["jobs"].get(job, {}) + _check(definition.get("environment") == "artifacts", f"{name} {job} runs in the artifacts environment") + used = set(re.findall(r"secrets\.([A-Za-z0-9_]+)", yaml.dump(definition))) + _check(used <= ARTIFACT_SECRETS, f"{name} {job} uses only R2 upload secrets (found {sorted(used)})") gate = yaml.load(open(os.path.join(WORKFLOWS, "iroh-release-gate.yml"), encoding="utf-8"), Loader=yaml.BaseLoader) condition = " ".join(str(gate["jobs"]["simulator-e2e"].get("if", "")).split()) _check( From ca8ef9fd721218683aa7e2be6d200ff4a7a8ee5d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 30 Sep 2026 16:02:58 -0700 Subject: [PATCH 6/7] fix(cloud): make Cloud workspace reconciliation always settle (#16158) * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- .../CloudWorkspaceProjectionCoordinator.swift | 63 ++++++++++++++ Sources/Surfaces/SurfaceCatalog.swift | 36 ++++---- .../CloudWorkspaceLiveProjectionTests.swift | 83 +++++++++++++++++++ 3 files changed, 165 insertions(+), 17 deletions(-) diff --git a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift index 7b80ceb29185..e3b64547672f 100644 --- a/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift +++ b/Sources/Surfaces/CloudWorkspaceProjectionCoordinator.swift @@ -43,6 +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 budget = CloudWorkspaceReconcileBudget() while self.requested.remove(machine) != nil { guard !Task.isCancelled, self.localMutations[machine] == nil else { return } await catalog.cloudPlacementCoordinator.waitForPendingMutations() @@ -51,12 +52,44 @@ final class CloudWorkspaceProjectionCoordinator { let state = catalog.cloudStates[machine], catalog.cloudStateObservations[machine]?.freshness == .current, catalog.cloudPlacementCoordinator.allowsNativeReconciliation(state) else { return } + 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) } } tasks[machine] = CloudWorkspaceProjectionTask(id: id, task: task) } + /// Daemon generation last reported per machine. A persistent re-requester + /// would otherwise report once for every graph revision. + private var reportedNonConvergence: [SurfaceMachineID: String] = [:] + + /// 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 + 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, + "generation": generation, "revision": state.cursor?.revision ?? 0, + ] + ) + } + /// 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 { @@ -96,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 } } @@ -170,3 +204,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/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index acb4e6e8949a..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) } @@ -1264,23 +1269,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 diff --git a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift index cb52fca98d4c..91092cc382bf 100644 --- a/cmuxTests/CloudWorkspaceLiveProjectionTests.swift +++ b/cmuxTests/CloudWorkspaceLiveProjectionTests.swift @@ -262,6 +262,89 @@ 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 }) + #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] + + 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) + } + + /// 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 <= 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 16:03:59 -0700 Subject: [PATCH 7/7] fix(tests): name the app's window-chrome sidebar options explicitly #11539 reverted #14991's qualification in SidebarWidthPolicyTests, so SidebarMaterialOption.sidebar is ambiguous between CmuxSettings and the app's typealias to WindowChromeSidebarMaterialOption. Use the WindowChrome names again, as #14991 did. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmuxTests/SidebarWidthPolicyTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index f423bc77a87b..9edb6d41898b 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -526,9 +526,9 @@ struct SidebarWorkspaceSelectionColorTests { terminalRenderingMode: .windowHostBackdrop, unifySurfaceBackdrops: true, sidebarSettings: SidebarBackdropSettingsSnapshot( - materialRawValue: SidebarMaterialOption.sidebar.rawValue, - blendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, - stateRawValue: SidebarStateOption.followWindow.rawValue, + materialRawValue: WindowChromeSidebarMaterialOption.sidebar.rawValue, + blendModeRawValue: WindowChromeSidebarBlendModeOption.withinWindow.rawValue, + stateRawValue: WindowChromeSidebarStateOption.followWindow.rawValue, tintHex: SidebarTintDefaults().hex, tintHexLight: nil, tintHexDark: nil, @@ -538,7 +538,7 @@ struct SidebarWorkspaceSelectionColorTests { colorScheme: .light ), windowGlassSettings: WindowGlassSettingsSnapshot( - sidebarBlendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, + sidebarBlendModeRawValue: WindowChromeSidebarBlendModeOption.withinWindow.rawValue, isEnabled: false, tintHex: "#000000", tintOpacity: 0,