mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
a8dc0683cc
commit
167a8ef20a
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user