fix(onboarding): harden fresh local startup and activation (#108764)

* fix(macos): preserve gateway during fresh setup

* fix(macos): validate reusable gateway service

* chore(macos): sync native i18n inventory

* fix(macos): preserve latest gateway service request

* test(macos): isolate gateway port concurrency coverage

* fix(macos): recover gateways after failed readiness

* fix(macos): repair wedged gateway listeners

* fix(macos): bound gateway startup probes

* chore(macos): sync native i18n inventory

* fix(onboarding): activate verified inference immediately

* fix(macos): isolate readiness failure generations

* chore(macos): refresh native i18n inventory

* fix(macos): serialize gateway service lifecycle

* fix(macos): sequence gateway startup persistence

* fix(macos): close gateway readiness races

* fix(macos): publish recovered gateway state

* fix(macos): make gateway readiness generation-safe

* chore(macos): refresh native i18n inventory

* fix(macos): preserve gateway endpoint identity

* fix(macos): refresh gateway ownership after attach

* fix(macos): distinguish transient gateway readiness

* refactor(macos): split gateway readiness lifecycle

* chore(macos): refresh native i18n inventory

* chore(release): keep changelog release-owned
This commit is contained in:
Peter Steinberger
2026-07-16 17:24:53 -07:00
committed by GitHub
parent a8dc0683cc
commit 167a8ef20a
7 changed files with 2624 additions and 82 deletions
+5 -5
View File
@@ -29883,7 +29883,7 @@
},
{
"kind": "conditional-branch",
"line": 18,
"line": 78,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "Stopped",
"surface": "apple",
@@ -29891,7 +29891,7 @@
},
{
"kind": "conditional-branch",
"line": 19,
"line": 79,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "Starting…",
"surface": "apple",
@@ -29899,7 +29899,7 @@
},
{
"kind": "conditional-branch",
"line": 28,
"line": 88,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "Failed: \\(reason)",
"surface": "apple",
@@ -29907,7 +29907,7 @@
},
{
"kind": "conditional-branch",
"line": 257,
"line": 571,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "not linked",
"surface": "apple",
@@ -29915,7 +29915,7 @@
},
{
"kind": "conditional-branch",
"line": 270,
"line": 584,
"path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift",
"source": "unknown error",
"surface": "apple",
@@ -7,10 +7,13 @@ final class ConnectionModeCoordinator {
private let logger = Logger(subsystem: "ai.openclaw", category: "connection")
private var lastMode: AppState.ConnectionMode?
private var applyGeneration: UInt64 = 0
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
self.applyGeneration &+= 1
let applyGeneration = self.applyGeneration
if let lastMode = self.lastMode, lastMode != mode {
GatewayProcessManager.shared.clearLastFailure()
NodesStore.shared.lastError = nil
@@ -29,19 +32,29 @@ final class ConnectionModeCoordinator {
case .local:
_ = await NodeServiceManager.stop()
guard self.applyGeneration == applyGeneration else { return }
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
guard self.applyGeneration == applyGeneration else { return }
WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
if shouldStart {
GatewayProcessManager.shared.setActive(true)
await GatewayProcessManager.shared.waitForStartupAttempt()
guard self.applyGeneration == applyGeneration else { return }
var launchAgentInstalled = false
if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: paused)
{
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
launchAgentInstalled = await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded()
}
_ = await GatewayProcessManager.shared.waitForGatewayReady()
guard self.applyGeneration == applyGeneration else { return }
// Always finish the generation-aware health audit after persistence work. A newer
// inactive lifecycle makes this return false without touching its repair marker.
_ = await GatewayProcessManager.shared.waitForGatewayReady(
launchAgentInstalled: launchAgentInstalled)
guard self.applyGeneration == applyGeneration else { return }
} else {
GatewayProcessManager.shared.stop()
}
@@ -1,6 +1,11 @@
import Foundation
enum GatewayLaunchAgentManager {
struct LoadedGatewayState: Equatable, Sendable {
let runningPID: Int32?
let reusablePID: Int32?
}
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd")
private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent"
@@ -58,9 +63,24 @@ enum GatewayLaunchAgentManager {
return nil
}
static func isLoaded() async -> Bool {
guard let loaded = await self.readDaemonLoaded() else { return false }
return loaded
static func reusableLoadedGatewayPID(port: Int) async -> Int32? {
await self.loadedGatewayState(port: port).reusablePID
}
static func loadedGatewayState(port: Int) async -> LoadedGatewayState {
guard let service = await self.readDaemonService() else {
return LoadedGatewayState(runningPID: nil, reusablePID: nil)
}
let runningPID = self.runningGatewayPID(from: service)
let configAudit = service["configAudit"] as? [String: Any]
let reusablePID: Int32? = if configAudit?["ok"] as? Bool == true,
self.gatewayPort(from: service) == port
{
runningPID
} else {
nil
}
return LoadedGatewayState(runningPID: runningPID, reusablePID: reusablePID)
}
static func runningGatewayPID() async -> Int32? {
@@ -70,7 +90,7 @@ enum GatewayLaunchAgentManager {
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
_ = bundlePath
guard !CommandResolver.connectionModeIsRemote() else {
if enabled, CommandResolver.connectionModeIsRemote() {
self.logger.info("launchd change skipped (remote mode)")
return nil
}
@@ -136,10 +156,6 @@ enum GatewayLaunchAgentManager {
}
extension GatewayLaunchAgentManager {
private static func readDaemonLoaded() async -> Bool? {
await self.readDaemonService()?["loaded"] as? Bool
}
private static func readDaemonService() async -> [String: Any]? {
let result = await self.runDaemonCommandResult(
["status", "--json", "--no-probe"],
@@ -155,6 +171,33 @@ extension GatewayLaunchAgentManager {
return service
}
private static func gatewayPort(from service: [String: Any]) -> Int? {
guard let command = service["command"] as? [String: Any] else { return nil }
if let arguments = command["programArguments"] as? [String] {
for (index, argument) in arguments.enumerated() {
if argument == "--port" {
guard arguments.indices.contains(index + 1) else { return nil }
return self.validGatewayPort(arguments[index + 1])
}
if argument.hasPrefix("--port=") {
return self.validGatewayPort(String(argument.dropFirst("--port=".count)))
}
}
}
let environment = command["environment"] as? [String: Any]
return self.validGatewayPort(environment?["OPENCLAW_GATEWAY_PORT"] as? String)
}
private static func validGatewayPort(_ raw: String?) -> Int? {
guard let raw,
let port = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
(1...65535).contains(port)
else {
return nil
}
return port
}
private static func runningGatewayPID(from service: [String: Any]) -> Int32? {
guard service["loaded"] as? Bool == true,
let runtime = service["runtime"] as? [String: Any],
@@ -197,9 +240,21 @@ extension GatewayLaunchAgentManager {
#if DEBUG
if self.testingInterceptDaemonCommands {
self.testingDaemonCommandCalls.append(args)
if self.testingDaemonCommandDelayNanoseconds > 0 {
try? await Task.sleep(nanoseconds: self.testingDaemonCommandDelayNanoseconds)
}
let payload = if args.first == "status" {
if self.testingDaemonStatusPayloads.isEmpty {
self.testingDaemonStatusPayload ?? "{\"ok\":true}"
} else {
self.testingDaemonStatusPayloads.removeFirst()
}
} else {
"{\"ok\":true}"
}
return CommandResult(
success: true,
payload: Data("{\"ok\":true}".utf8),
payload: Data(payload.utf8),
message: nil)
}
#endif
@@ -251,6 +306,9 @@ extension GatewayLaunchAgentManager {
private nonisolated(unsafe) static var testingDisableLaunchAgentMarkerURL: URL?
private nonisolated(unsafe) static var testingInterceptDaemonCommands = false
private nonisolated(unsafe) static var testingDaemonCommandCalls: [[String]] = []
private nonisolated(unsafe) static var testingDaemonStatusPayload: String?
private nonisolated(unsafe) static var testingDaemonStatusPayloads: [String] = []
private nonisolated(unsafe) static var testingDaemonCommandDelayNanoseconds: UInt64 = 0
static func setTestingDisableLaunchAgentMarkerURL(_ url: URL?) {
self.testingDisableLaunchAgentMarkerURL = url
@@ -260,6 +318,20 @@ extension GatewayLaunchAgentManager {
self.testingInterceptDaemonCommands = intercept
}
static func setTestingDaemonStatusPayload(_ payload: String?) {
self.testingDaemonStatusPayload = payload
self.testingDaemonStatusPayloads = []
}
static func setTestingDaemonStatusPayloads(_ payloads: [String]) {
self.testingDaemonStatusPayload = nil
self.testingDaemonStatusPayloads = payloads
}
static func setTestingDaemonCommandDelayNanoseconds(_ nanoseconds: UInt64) {
self.testingDaemonCommandDelayNanoseconds = nanoseconds
}
static func clearTestingDaemonCommandCalls() {
self.testingDaemonCommandCalls.removeAll(keepingCapacity: false)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -3614,10 +3614,7 @@ describe("activateSetupInference", () => {
]);
expect(transformConfig).toHaveBeenCalledWith(
expect.objectContaining({
afterWrite: {
mode: "none",
reason: "OpenClaw activates verified inference",
},
afterWrite: { mode: "auto" },
}),
);
expect(refreshPluginRegistry).toHaveBeenCalledWith({
+3 -1
View File
@@ -2036,7 +2036,9 @@ async function activateSetupInferenceUnredacted(
base: "source",
// The transform stays side-effect free so a config conflict can retry
// without replaying credential writes in another agent directory.
afterWrite: { mode: "none", reason: "OpenClaw activates verified inference" },
// Setup changes only hot-reloadable model, agent, and plugin-entry surfaces.
// Publish the verified route now so the next turn cannot reuse the old harness.
afterWrite: { mode: "auto" },
transform: async (current, context) => {
const latestRuntime = context.snapshot.runtimeConfig ?? context.snapshot.config;
// Validate that the candidate is still admissible before reporting