diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index 389d5a9f4566..73870a898400 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -573,7 +573,8 @@ enum CLIInstaller { paused: Bool = AppStateStore.shared.isPaused, start: @MainActor () -> Void = { GatewayProcessManager.shared.setActive(true) }, waitUntilReady: @MainActor () async -> Bool = { - await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) + await GatewayProcessManager.shared.waitForGatewayReady( + timeout: GatewayLaunchAgentManager.startupMigrationTolerance) }) async -> LocalGatewayActivation { guard mode == .local, !paused else { return .deferred } diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index 866db2e8a5db..d03af558beb0 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -3,6 +3,8 @@ import Foundation enum CommandResolver { private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath" private static let helperName = "openclaw" + /// Version probes may queue under machine load; keep command resolution tolerant but bounded. + static let versionProbeTimeout: TimeInterval = 10 static func gatewayEntrypoint(in root: URL) -> String? { let distEntry = root.appendingPathComponent("dist/index.js").path diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index 2633c131693b..75185b088fcd 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -332,7 +332,7 @@ enum GatewayEnvironment { path: binary, arguments: ["--version"], environment: ["PATH": searchPaths.joined(separator: ":")], - timeout: 2) + timeout: CommandResolver.versionProbeTimeout) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) if elapsedMs > 500 { self.logger.warning( diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index fe903a5cd7a7..7bff6a706f76 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -8,6 +8,9 @@ enum GatewayLaunchAgentManager { private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd") private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent" + /// A first-run daemon command may wait behind state integrity checks and the shared startup- + /// migration lease. Keep the app from killing healthy migration work before it can finish. + static let startupMigrationTolerance: TimeInterval = 120 private static var disableLaunchAgentMarkerURL: URL { #if DEBUG @@ -130,7 +133,7 @@ enum GatewayLaunchAgentManager { self.logger.info("launchd restart skipped (disable marker set)") return nil } - return await self.runDaemonCommand(["restart"], timeout: 20) + return await self.runDaemonCommand(["restart"]) } static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { @@ -234,7 +237,7 @@ extension GatewayLaunchAgentManager { private static func runDaemonCommand( _ args: [String], - timeout: Double = 15, + timeout: Double = Self.startupMigrationTolerance, quiet: Bool = false) async -> String? { let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet) diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift index ca731e99787d..3047444c9240 100644 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -698,14 +698,13 @@ extension GatewayProcessManager { context: LaunchAgentStartupContext, startGeneration: UInt64, readinessWindow: TimeInterval = 6, - // A fresh install gets ten six-second probe windows for Local Network authorization. - firstInstallReadinessGraceWindows: Int = 9) async + // Fresh installs keep probing through the same first-run migration budget as the CLI. + firstInstallReadinessBudget: TimeInterval = GatewayLaunchAgentManager.startupMigrationTolerance) async { let startedAt = Date() var deadline = startedAt.addingTimeInterval(readinessWindow) - let graceWindowCount = max(0, firstInstallReadinessGraceWindows) let finalProbeDeadline = startedAt.addingTimeInterval( - readinessWindow * (Double(graceWindowCount) + 1)) + max(readinessWindow, firstInstallReadinessBudget)) var latestRetryDisposition: GatewayProbeFailureDisposition? var readinessPID = context.readinessPID var freshInstallGraceAuthorized = false @@ -983,7 +982,8 @@ extension GatewayProcessManager { launchAgentInstalled: Bool = false) async -> Bool { let startGeneration = self.gatewayStartGeneration - if let result = await self.observeCurrentGatewayStart(generation: startGeneration) { return result } + if await self.observeCurrentGatewayStart(generation: startGeneration) == true { return true } + guard !Task.isCancelled, self.isCurrentGatewayStart(startGeneration) else { return false } let readinessCandidate = self.launchAgentReadinessCandidate let readinessFailure = self.launchAgentReadinessFailure let readinessRevision = self.launchAgentReadinessRevision @@ -1337,7 +1337,7 @@ extension GatewayProcessManager { port: Int, pid: Int32, readinessWindow: TimeInterval, - firstInstallReadinessGraceWindows: Int) + firstInstallReadinessBudget: TimeInterval) { self.desiredActive = true self.status = .starting @@ -1354,7 +1354,7 @@ extension GatewayProcessManager { readinessRevision: readinessRevision), startGeneration: generation, readinessWindow: readinessWindow, - firstInstallReadinessGraceWindows: firstInstallReadinessGraceWindows) + firstInstallReadinessBudget: firstInstallReadinessBudget) } } } diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift index 9547e76fd27e..0d7cfb5e90bc 100644 --- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift +++ b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift @@ -147,7 +147,7 @@ enum RuntimeLocator { path: binary, arguments: ["--version"], environment: ["PATH": pathEnv], - timeout: 2) + timeout: CommandResolver.versionProbeTimeout) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) if elapsedMs > 500 { self.logger.warning( diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift index e41b707b9a00..054e5fc9fde7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift @@ -72,6 +72,22 @@ struct GatewayEnvironmentTests { #expect(version == "2026.7.29") } + @Test func `gateway version probe tolerates loaded host delay`() async throws { + let root = try makeTempDirForTests() + defer { try? FileManager.default.removeItem(at: root) } + let gateway = root.appendingPathComponent("openclaw") + try "#!/bin/sh\nsleep 2.1\necho OpenClaw 2026.7.30\n" + .write(to: gateway, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: gateway.path) + + let version = await GatewayEnvironment.installedGatewayVersion( + gatewayBin: gateway.path, + projectRoot: root, + searchPaths: [root.path, "/usr/bin", "/bin"]) + + #expect(version == "2026.7.30") + } + @Test func `gateway launch resolution scans preferred paths once`() async throws { let root = try makeTempDirForTests() defer { try? FileManager.default.removeItem(at: root) } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift index acd7f2507fd2..823545a26b7f 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift @@ -4,6 +4,10 @@ import Testing @Suite(.serialized) struct GatewayLaunchAgentManagerTests { + @Test func `daemon commands tolerate first run state migrations`() { + #expect(GatewayLaunchAgentManager.startupMigrationTolerance >= 120) + } + @Test func `reads Gateway service ownership command directly from launchd`() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-gateway-\(UUID().uuidString).plist") diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index 7aa128ca73e7..05c5c50ab136 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -980,11 +980,13 @@ struct GatewayProcessManagerTests { } } - @Test func `readiness waiter observes the current owner past its timeout`() async throws { + @Test func `readiness waiter rechecks after current owner fails past its timeout`() async throws { let port = 19114 let url = try #require(URL(string: "ws://example.invalid")) let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) { - self.gatewayTask(healthSucceedsAfter: 1) + self.gatewayTask( + healthSucceedsAfter: 0, + stallsFirstHealthResponse: true) } defer { manager.setTestingConnection(nil) } @@ -1007,7 +1009,7 @@ struct GatewayProcessManagerTests { port: port, pid: 4242, readinessWindow: 0.5, - firstInstallReadinessGraceWindows: 1) + firstInstallReadinessBudget: 0.5) let readiness = Task { @MainActor in await manager.waitForGatewayReady(timeout: 0.01) } @@ -1019,6 +1021,7 @@ struct GatewayProcessManagerTests { #expect(!manager._testHasLaunchAgentReadinessFailure()) #expect(await readiness.value) #expect(manager.status == .running(details: "pid 4242")) + #expect((session.latestTask()?.snapshotSendCount() ?? 0) > 1) await connection.shutdown() await PortGuardian.shared.setTestingDescriptor(nil, forPort: port) @@ -1046,7 +1049,7 @@ struct GatewayProcessManagerTests { port: port, pid: 4242, readinessWindow: 0.5, - firstInstallReadinessGraceWindows: 1) + firstInstallReadinessBudget: 1) let readiness = Task { @MainActor in await manager.waitForGatewayReady(timeout: 0.01) } @@ -1095,7 +1098,7 @@ struct GatewayProcessManagerTests { port: port, pid: 4242, readinessWindow: 0.05, - firstInstallReadinessGraceWindows: 2) + firstInstallReadinessBudget: 0.15) await manager.waitForStartupAttempt() #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot() @@ -1138,7 +1141,7 @@ struct GatewayProcessManagerTests { port: port, pid: 4242, readinessWindow: 0.01, - firstInstallReadinessGraceWindows: 1) + firstInstallReadinessBudget: 0.02) await manager.waitForStartupAttempt() #expect(manager.status == .failed("Gateway did not start in time")) @@ -1180,7 +1183,7 @@ struct GatewayProcessManagerTests { port: port, pid: 4242, readinessWindow: 0.05, - firstInstallReadinessGraceWindows: 1) + firstInstallReadinessBudget: 0.1) await manager.waitForStartupAttempt() guard case .failed("Gateway did not start in time") = manager.status else { Issue.record("fresh launchd readiness did not fail within its bounded grace") diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 05eb17bfee84..f9df0fa1d411 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -28,6 +28,21 @@ struct RuntimeLocatorTests { #expect(res.version == RuntimeVersion(major: 22, minor: 22, patch: 3)) } + @Test func `runtime version probe tolerates loaded host delay`() async throws { + let script = """ + #!/bin/sh + /bin/sleep 2.1 + echo v22.22.3 + """ + let node = try self.makeTempExecutable(contents: script) + let result = await RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .success(resolution) = result else { + Issue.record("Expected delayed version probe to succeed, got \(result)") + return + } + #expect(resolution.version == RuntimeVersion(major: 22, minor: 22, patch: 3)) + } + @Test func `resolve fails on boundary below minimum`() async throws { let script = """ #!/bin/sh