mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 07:36:10 +00:00
* 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
173 lines
5.8 KiB
Swift
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?()
|
|
}
|
|
}
|