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/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)] = [] 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 new file mode 100644 index 000000000000..ec86aa8dc5db --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/OpenCodePaths.swift @@ -0,0 +1,54 @@ +import Foundation + +/// The filesystem locations used by OpenCode, resolved from its documented +/// environment overrides. +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 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 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) + } + } + + 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..3dd7a76e829d 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 { @@ -200,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/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/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") })) 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..