diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index 6b98f24f9d0c..f08761140066 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -201,7 +201,7 @@ enum CLIInstaller { expectedVersion: GatewayEnvironment.expectedGatewayVersionString(), preferredPaths: preferredPaths) if status.isReady { - self.rememberValidated(status) + self.rememberValidated(status, defaults: AppDefaults.standard) return status } fallbackStatus = fallbackStatus ?? status @@ -225,7 +225,7 @@ enum CLIInstaller { expectedVersion: expectedVersion, preferredPaths: preferredPaths) if status.isReady { - self.rememberValidated(status) + self.rememberValidated(status, defaults: AppDefaults.standard) } return status } @@ -316,10 +316,14 @@ enum CLIInstaller { return environment } - private static func rememberValidated(_ status: Status) { + static func rememberValidated(_ status: Status, defaults: UserDefaults) { guard case let .ready(location, version) = status else { return } - AppDefaults.standard.set(location, forKey: cliValidatedExecutableKey) - AppDefaults.standard.set(version, forKey: cliValidatedVersionKey) + if defaults.string(forKey: cliValidatedExecutableKey) != location { + defaults.set(location, forKey: cliValidatedExecutableKey) + } + if defaults.string(forKey: cliValidatedVersionKey) != version { + defaults.set(version, forKey: cliValidatedVersionKey) + } } @discardableResult diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index d692136cf504..2de0ce6561e9 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -936,6 +936,12 @@ extension MacNodeModeCoordinator { guard self.nodeHostWorkerRetryTask == nil else { throw MacNodeHostWorkerRetryPolicy.RetryBackoffPending() } + if let activeInput = self.activeNodeHostWorkerInput { + // Worker launch metadata is startup-scoped. Route retries reuse it instead of + // repeating CLI and runtime discovery until an explicit restart resets state. + try self.nodeHostWorkerRetryPolicy.prepareForStart(activeInput) + return try await nodeHostWorker.start(launch: activeInput.launch) + } let launch: MacNodeHostWorkerLaunch do { if let projectLaunch = try await CommandResolver.projectNodeHostWorkerLaunch() { diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index a24faffd4a5c..c2298f3ae538 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -1,4 +1,5 @@ import Foundation +import os import Testing @testable import OpenClaw @@ -291,6 +292,46 @@ struct CLIInstallerTests { defaults: defaults) == "2026.7.2") } + @Test func `validated CLI cache changes only when the ready tuple changes`() throws { + let suite = "CLIInstallerTests.validated-cache.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let notificationCount = OSAllocatedUnfairLock(initialState: 0) + let initialLocation = "/Users/test/.local/bin/openclaw" + defaults.set(initialLocation, forKey: cliValidatedExecutableKey) + defaults.set("2026.8.1", forKey: cliValidatedVersionKey) + let observer = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: defaults, + queue: nil) + { _ in + notificationCount.withLock { $0 += 1 } + } + defer { NotificationCenter.default.removeObserver(observer) } + + CLIInstaller.rememberValidated( + .ready(location: initialLocation, version: "2026.8.1"), + defaults: defaults) + + #expect(notificationCount.withLock { $0 } == 0) + + let updatedLocation = "/opt/homebrew/bin/openclaw" + CLIInstaller.rememberValidated( + .ready(location: updatedLocation, version: "2026.8.1"), + defaults: defaults) + + #expect(notificationCount.withLock { $0 } == 1) + #expect(defaults.string(forKey: cliValidatedExecutableKey) == updatedLocation) + #expect(defaults.string(forKey: cliValidatedVersionKey) == "2026.8.1") + + CLIInstaller.rememberValidated( + .ready(location: updatedLocation, version: "2026.8.2"), + defaults: defaults) + + #expect(notificationCount.withLock { $0 } == 2) + #expect(defaults.string(forKey: cliValidatedVersionKey) == "2026.8.2") + } + @Test func `managed setup requires a parseable compatible version`() { let location = "/Users/test/.openclaw/bin/openclaw"