Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingConfiguredGatewayProbe.swift
Peter Steinberger 96f0983a85 fix(onboarding): skip setup for configured gateways and require inference first (#102883)
* fix(crestodian): keep onboarding RPCs restart-safe

* fix(profiles): isolate approval state migrations

* fix(crestodian): bypass configured gateway setup

* test(crestodian): type onboarding mocks

* fix(onboarding): require inference before Crestodian

* fix(onboarding): enforce verified inference handoff

* fix(macos): reset setup on gateway endpoint edits

* chore(i18n): refresh native source inventory

* fix(gateway): keep socket on request cancellation

* test(packaging): require workspace templates

* fix(onboarding): bind setup to verified inference

* fix(onboarding): align inference gate contracts

* fix(crestodian): classify concurrent policy rejection

* test(crestodian): expect registry restoration

* fix(onboarding): bind setup to configured gateways

* fix(codex): preserve startup phase deadlines

* test(crestodian): match fail-closed policy ordering

* test(onboarding): assert bound gateway handoff

* fix(codex): bind runtime resolution to spawn cwd

* test(crestodian): assert policy rejection order

* fix(cli): preserve gateway routing across restarts

* fix(macos): fail closed during gateway edits

* test(macos): cover gateway route generation races

* chore: keep release notes out of onboarding PR

* fix(ci): refresh onboarding generated checks

* style(swift): align gateway channel formatting

* fix(ci): refresh plugin SDK surface budgets

* fix(ci): resync native string inventory

* refactor(swift): split gateway channel support

* test(doctor): isolate plugin compatibility registry

* test(macos): isolate gateway onboarding fixtures

* test(macos): assert gateway lease health ordering

* fix(codex): reconcile computer-use startup changes
2026-07-11 10:25:14 -07:00

173 lines
5.8 KiB
Swift

import Foundation
/// Route-bound check used before onboarding starts creating inference config.
/// A superseded result must never complete onboarding for the replacement Gateway.
@MainActor
final class OnboardingConfiguredGatewayProbe {
struct Attempt: Equatable {
fileprivate let generation: UInt64
}
struct BoundRoute: Equatable {
fileprivate let route: GatewayConnection.Route
let identity: String?
}
enum Outcome: Equatable {
case configured(modelRef: String, route: BoundRoute)
case missing(route: BoundRoute)
case unavailable
case superseded
var boundRoute: BoundRoute? {
switch self {
case let .configured(_, route), let .missing(route):
route
case .unavailable, .superseded:
nil
}
}
}
private let gateway: GatewayConnection
private let timeoutMs: Double
private var generation: UInt64 = 0
private var activeProbeCount = 0
private var reconnectPending = false
private var reconnectHandler: (@MainActor () -> Void)?
private var pendingActivationDeadlineTask: Task<Void, Never>?
private var temporaryConnectionCheckDepth = 0
init(
gateway: GatewayConnection = .shared,
timeoutMs: Double = 15000)
{
self.gateway = gateway
self.timeoutMs = timeoutMs
}
/// Allocate before queuing async work so user-event order, not Task start
/// order, decides which selected Gateway owns the result.
func beginProbe() -> Attempt {
self.generation &+= 1
return Attempt(generation: self.generation)
}
func isCurrent(_ attempt: Attempt) -> Bool {
self.generation == attempt.generation
}
var isSuppressedForTemporaryConnectionCheck: Bool {
self.temporaryConnectionCheckDepth > 0
}
func beginTemporaryConnectionCheck() {
self.temporaryConnectionCheckDepth += 1
// A probe already in flight for the committed selection must not finish
// against the temporary mode borrowed by Check connection.
self.invalidate()
}
func endTemporaryConnectionCheck() {
self.temporaryConnectionCheckDepth = max(0, self.temporaryConnectionCheckDepth - 1)
}
func invalidate() {
self.generation &+= 1
self.pendingActivationDeadlineTask?.cancel()
self.pendingActivationDeadlineTask = nil
}
func schedulePendingActivationRecheck(
deadline: Date,
onElapsed: @escaping @MainActor () -> Void)
{
self.pendingActivationDeadlineTask?.cancel()
let generation = self.generation
let delay = max(0, deadline.timeIntervalSinceNow)
self.pendingActivationDeadlineTask = Task { @MainActor [weak self] in
do {
try await Task.sleep(for: .seconds(delay))
} catch {
return
}
guard let self, self.generation == generation else { return }
self.pendingActivationDeadlineTask = nil
onElapsed()
}
}
func cancelPendingActivationRecheck() {
self.pendingActivationDeadlineTask?.cancel()
self.pendingActivationDeadlineTask = nil
}
func probe(
connectionMode: AppState.ConnectionMode,
attempt: Attempt,
routeIdentity: String? = nil) async -> Outcome
{
guard self.isCurrent(attempt) else { return .superseded }
self.activeProbeCount += 1
defer { self.finishProbe() }
guard connectionMode != .unconfigured else { return .unavailable }
guard let route = await gateway.captureRoute() else {
return self.isCurrent(attempt) ? .unavailable : .superseded
}
guard self.isCurrent(attempt) else { return .superseded }
let boundRoute = BoundRoute(route: route, identity: routeIdentity)
do {
let model = try await gateway.configuredInferenceModel(
ifCurrentRoute: route,
timeoutMs: self.timeoutMs)
guard await self.gateway.isCurrentRoute(route),
self.isCurrent(attempt)
else { return .superseded }
guard let model = model?.trimmingCharacters(in: .whitespacesAndNewlines),
!model.isEmpty
else { return .missing(route: boundRoute) }
return .configured(modelRef: model, route: boundRoute)
} catch is CancellationError {
return .superseded
} catch {
guard await self.gateway.isCurrentRoute(route),
self.isCurrent(attempt)
else { return .superseded }
return .unavailable
}
}
func isCurrent(_ route: BoundRoute) async -> Bool {
await self.gateway.isCurrentRoute(route.route)
}
func consumeReconnects(onReconnect: @escaping @MainActor () -> Void) async {
self.reconnectHandler = onReconnect
defer {
self.reconnectHandler = nil
self.reconnectPending = false
}
let stream = await gateway.subscribe(bufferingNewest: 1)
for await push in stream {
guard !Task.isCancelled else { return }
guard case .snapshot = push else { continue }
// captureRoute can create the socket whose hello produced this
// snapshot. Coalesce it until that route-bound check finishes so a
// real reconnect is never lost behind the in-flight request.
guard self.activeProbeCount == 0 else {
self.reconnectPending = true
continue
}
onReconnect()
}
}
private func finishProbe() {
self.activeProbeCount -= 1
guard self.activeProbeCount == 0, self.reconnectPending else { return }
self.reconnectPending = false
self.reconnectHandler?()
}
}