fix: macOS onboarding waits for Gateway restart (#127713)

* fix(onboarding): wait for inference gateway restart

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* fix(onboarding): preserve custodian handoff after restart

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* refactor(macos): share activation restart finalization

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* style(macos): format restart reconciliation

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* fix(macos): compile restart finalization

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* test(macos): sequence onboarding restart proof

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* test(macos): finish onboarding after activation

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>

* test(macos): reuse managed restart proof

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

Co-authored-by: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>

* test(macos): assert receipt before handoff cleanup

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

Co-authored-by: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>

* fix(onboarding): keep restart verification bounded

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

Co-authored-by: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com>

---------

Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>
This commit is contained in:
ClawSweeper
2026-08-22 15:51:04 -07:00
committed by GitHub
parent a3c007fb45
commit 7d95cff39d
11 changed files with 378 additions and 110 deletions
@@ -905,28 +905,27 @@ extension OnboardingAISetupModel {
context: context)
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard await self.gateway.isCurrentServerLease(lease) else {
if result.ok,
OnboardingSystemAgentResumeStore.markCompleted(
ifOwnedBy: context.routeIdentity,
activationOwner: activationOwner,
defaults: self.defaults)
if result.ok {
if let failure = await finishSuccessfulActivation(
kind: kind,
expectedModel: modelRef,
context: context,
activationOwner: activationOwner,
before: persistedStateBeforeActivation,
originalServerLease: lease,
gatewayRestartRequired: result.gatewayRestartRequired == true)
{
self.pendingActivationVerification = true
self.phase = .detecting
_ = await self.verifyPendingConfiguredInference()
} else {
self.statuses[kind] = .failed(failure)
self.exposeActivationFailure(failure, whenTerminal: !tryNextCandidateOnFailure)
}
} else {
guard await self.gateway.isCurrentServerLease(lease) else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed while AI setup was finishing. Check again."))
return
}
return
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
if result.ok {
finishConnected(kind: kind, activationOwner: activationOwner)
} else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
let failure = Self.failure(
@@ -1034,17 +1033,20 @@ extension OnboardingAISetupModel {
guard self.isCurrentAttempt(context), !Task.isCancelled else { return false }
let leaseTimeoutMs = deadline.remainingMilliseconds(cappedAt: 3000)
guard leaseTimeoutMs > 0 else { return false }
if let replacementLease = try? await gateway.acquireServerLease(
ifSameRouteAs: originalServerLease,
timeoutMs: Double(leaseTimeoutMs)),
await reconcilePersistedActivation(
kind: kind,
expectedModel: expectedModel,
context: context,
activationOwner: activationOwner,
before: before,
serverLease: replacementLease,
deadline: deadline)
// A successful activation reply can precede its deferred restart.
// Never verify or hand off on the physical socket that scheduled it.
if await !(self.gateway.isCurrentServerLease(originalServerLease)),
let replacementLease = try? await self.gateway.acquireServerLease(
ifSameRouteAs: originalServerLease,
timeoutMs: Double(leaseTimeoutMs)),
await self.reconcilePersistedActivation(
kind: kind,
expectedModel: expectedModel,
context: context,
activationOwner: activationOwner,
before: before,
serverLease: replacementLease,
deadline: deadline)
{
self.serverLease = replacementLease
return true
@@ -1061,6 +1063,40 @@ extension OnboardingAISetupModel {
return false
}
private func finishSuccessfulActivation(
kind: String,
expectedModel: String,
context: AttemptContext,
activationOwner: OnboardingSystemAgentResumeStore.ActivationOwner,
before: PersistedActivationState?,
originalServerLease: GatewayConnection.ServerLease,
gatewayRestartRequired: Bool) async -> Failure?
{
let originalLeaseWasReplaced = await !(self.gateway.isCurrentServerLease(originalServerLease))
let restartRequired = gatewayRestartRequired || originalLeaseWasReplaced
guard self.isCurrentAttempt(context), !Task.isCancelled else { return nil }
guard restartRequired else {
self.finishConnected(kind: kind, activationOwner: activationOwner)
return nil
}
self.pendingActivationVerification = true
self.phase = .detecting
if await self.reconcileActivationAfterGatewayRestart(
kind: kind,
expectedModel: expectedModel,
context: context,
activationOwner: activationOwner,
before: before,
originalServerLease: originalServerLease)
{
return nil
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return nil }
self.phase = .ready
return Self.transportFailure(
"The Gateway did not finish restarting after AI setup. Try again once it is available.")
}
private func reconcilePersistedActivation(
kind: String,
expectedModel: String,
@@ -1119,26 +1155,7 @@ extension OnboardingAISetupModel {
}
extension OnboardingAISetupModel {
func startProviderAuth(_ option: AuthOption) {
self.startProviderWizard(option, kind: .auth)
}
func startProviderPrepare(_ option: PrepareOption) {
self.startProviderWizard(
AuthOption(
id: option.id,
brandId: option.brandId,
label: option.label,
hint: option.hint,
groupLabel: nil,
icon: option.icon,
website: option.website,
kind: "prepare",
featured: false),
kind: .prepare)
}
private func startProviderWizard(_ option: AuthOption, kind: ProviderWizardKind) {
func startProviderWizard(_ option: AuthOption, kind: ProviderWizardKind) {
guard !self.isBusy, self.activeAuthOption == nil, let serverLease else { return }
self.activeAuthOption = option
self.providerWizardKind = kind
@@ -1258,16 +1275,6 @@ extension OnboardingAISetupModel {
}
}
var authWizardOptions: [WizardOption] {
parseWizardOptions(self.authStep?.options)
}
var selectedAuthWizardOption: WizardOption? {
let options = self.authWizardOptions
guard options.indices.contains(self.authSelection) else { return options.first }
return options[self.authSelection]
}
private func advanceProviderAuth(stepID: String?, value: AnyCodable?) {
guard let sessionID = authSessionID, let serverLease else { return }
self.authBusy = true
@@ -1510,6 +1517,7 @@ extension OnboardingAISetupModel {
ifCurrentServerLease: lease)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
let requestTimeoutMs = Self.activationRequestTimeoutMs(for: "api-key")
let persistedStateBeforeActivation = self.lastDetectedActivationState
// Same keychain-unavailable degradation as detected candidates: an
// unbound lease keeps the ambiguity window without a resume receipt.
let activationOwner = routeFingerprint.map { fingerprint in
@@ -1547,31 +1555,24 @@ extension OnboardingAISetupModel {
ifCurrentServerLease: lease)
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard await self.gateway.isCurrentServerLease(lease) else {
if result.ok,
OnboardingSystemAgentResumeStore.markCompleted(
ifOwnedBy: context.routeIdentity,
activationOwner: activationOwner,
defaults: self.defaults)
{
self.pendingActivationVerification = true
self.phase = .detecting
_ = await self.verifyPendingConfiguredInference()
} else {
if result.ok {
self.manualKey = ""
self.manualError = await self.finishSuccessfulActivation(
kind: "api-key",
expectedModel: result.modelRef ?? "",
context: context,
activationOwner: activationOwner,
before: persistedStateBeforeActivation,
originalServerLease: lease,
gatewayRestartRequired: result.gatewayRestartRequired == true)
} else {
guard await self.gateway.isCurrentServerLease(lease) else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
self.requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed while AI setup was finishing. Check again."))
return
}
return
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
if result.ok {
self.manualKey = ""
self.finishConnected(
kind: "api-key",
activationOwner: activationOwner)
} else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
self.manualError = Self.failure(
@@ -1,6 +1,7 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
extension OnboardingAISetupModel {
struct PersistedActivationState: Equatable {
@@ -81,6 +82,7 @@ extension OnboardingAISetupModel {
let modelRef: String?
let status: String?
let error: String?
let gatewayRestartRequired: Bool?
}
struct Candidate: Identifiable, Equatable {
@@ -205,6 +207,16 @@ extension OnboardingAISetupModel {
self.providerWizardKind == .prepare
}
var authWizardOptions: [WizardOption] {
parseWizardOptions(self.authStep?.options)
}
var selectedAuthWizardOption: WizardOption? {
let options = self.authWizardOptions
guard options.indices.contains(self.authSelection) else { return options.first }
return options[self.authSelection]
}
var connected: Bool {
self.phase == .connected
}
@@ -219,6 +231,25 @@ extension OnboardingAISetupModel {
return !self.isBusy || (self.phase == .testing && self.selectedKind != kind)
}
func startProviderAuth(_ option: AuthOption) {
self.startProviderWizard(option, kind: .auth)
}
func startProviderPrepare(_ option: PrepareOption) {
self.startProviderWizard(
AuthOption(
id: option.id,
brandId: option.brandId,
label: option.label,
hint: option.hint,
groupLabel: nil,
icon: option.icon,
website: option.website,
kind: "prepare",
featured: false),
kind: .prepare)
}
/// True when setup live-verified an already-configured route instead of
/// activating a new one. The custodian first-run handoff belongs only to
/// fresh activations; verified reopens land on the normal dashboard.
@@ -660,7 +660,8 @@ private func makeRestartingAISetupSession(
suiteName: String,
recorder: AISetupRequestRecorder,
ownerObservation: ActivationOwnerObservation,
postRestartConfiguredModel: String?) -> GatewayTestWebSocketSession
postRestartConfiguredModel: String?,
replacementGate: AISetupRequestGate? = nil) -> GatewayTestWebSocketSession
{
let socketGeneration = AISetupSocketGeneration()
return GatewayTestWebSocketSession(taskFactory: {
@@ -693,6 +694,9 @@ private func makeRestartingAISetupSession(
}
switch request.method {
case "openclaw.setup.detect":
if let replacementGate {
await replacementGate.wait()
}
let response = postRestartConfiguredModel.map {
persistedDetectedSetupResponse(id: request.id, configuredModel: $0)
} ?? detectedSetupResponse(
@@ -713,11 +717,17 @@ private func failedActivationResponse(id: String) -> Data {
Data(#"{"type":"res","id":"\#(id)","ok":true,"payload":{"ok":false,"status":"auth","error":"rejected"}}"#.utf8)
}
private func successfulActivationResponse(id: String, modelRef: String, latencyMs: Int) -> Data {
Data(
private func successfulActivationResponse(
id: String,
modelRef: String,
latencyMs: Int,
gatewayRestartRequired: Bool = false) -> Data
{
let restartField = gatewayRestartRequired ? ",\"gatewayRestartRequired\":true" : ""
return Data(
"""
{"type":"res","id":"\(id)","ok":true,"payload":{
"ok":true,"modelRef":"\(modelRef)","latencyMs":\(latencyMs),"lines":["Model ready"]}}
"ok":true,"modelRef":"\(modelRef)","latencyMs":\(latencyMs),"lines":["Model ready"]\(restartField)}}
""".utf8)
}
@@ -1282,19 +1292,34 @@ struct OnboardingAISetupTests {
let defaults = try #require(isolatedAISetupDefaults(suiteName: suiteName))
let recorder = AISetupRequestRecorder()
let ownerObservation = ActivationOwnerObservation()
let replacementGate = AISetupRequestGate()
let session = makeRestartingAISetupSession(
suiteName: suiteName,
recorder: recorder,
ownerObservation: ownerObservation,
postRestartConfiguredModel: "openai/gpt-5.5")
postRestartConfiguredModel: "openai/gpt-5.5",
replacementGate: replacementGate)
let url = try #require(URL(string: "ws://example.invalid"))
let gateway = makeAISetupGateway(url: url, token: "route-token", session: session)
let model = makeAISetupModel(gateway: gateway, defaults: defaults)
var handoffCount = 0
model.onConnected = { handoffCount += 1 }
let appState = AppState(preview: true)
appState.connectionMode = .local
var handoffs: [OnboardingDashboardHandoff] = []
let view = OnboardingView(
state: appState,
aiSetupGateway: gateway,
systemAgentDefaults: defaults,
aiSetupRouteIdentityProvider: { "local" },
dashboardHandoffOpener: { handoffs.append($0) })
await model.detectAndAutoConnect()
await model.activate(kind: "codex-cli")
await view.aiSetup.detectAndAutoConnect()
let activation = Task { await view.aiSetup.activate(kind: "codex-cli") }
await replacementGate.waitUntilStarted()
guard case .activating = pendingState(defaults) else {
Issue.record("expected restart-required activation to remain pending")
return
}
await replacementGate.release()
await activation.value
let activationOwner = try #require(ownerObservation.value())
#expect(session.snapshotMakeCount() >= 2)
@@ -1304,11 +1329,12 @@ struct OnboardingAISetupTests {
"openclaw.setup.detect",
"openclaw.setup.verify",
])
#expect(model.connected)
#expect(model.selectedKind == "codex-cli")
#expect(handoffCount == 1)
#expect(view.aiSetup.connected)
#expect(view.aiSetup.selectedKind == "codex-cli")
#expect(storedActivationOwner(defaults) == activationOwner)
#expect(pendingState(defaults) == .completed)
#expect(view.finish())
#expect(handoffs == [.custodianOnboarding])
}
@Test func `managed Gateway restart rejects mismatched persisted transition`() async throws {
@@ -12396,6 +12396,7 @@ public struct SystemAgentSetupActivateResult: Codable, Sendable {
public let modelref: String?
public let latencyms: Double?
public let lines: [String]?
public let gatewayrestartrequired: Bool?
public let status: AnyCodable?
public let error: String?
@@ -12404,6 +12405,7 @@ public struct SystemAgentSetupActivateResult: Codable, Sendable {
modelref: String? = nil,
latencyms: Double? = nil,
lines: [String]? = nil,
gatewayrestartrequired: Bool? = nil,
status: AnyCodable? = nil,
error: String? = nil)
{
@@ -12411,6 +12413,7 @@ public struct SystemAgentSetupActivateResult: Codable, Sendable {
self.modelref = modelref
self.latencyms = latencyms
self.lines = lines
self.gatewayrestartrequired = gatewayrestartrequired
self.status = status
self.error = error
}
@@ -12420,6 +12423,7 @@ public struct SystemAgentSetupActivateResult: Codable, Sendable {
case modelref = "modelRef"
case latencyms = "latencyMs"
case lines
case gatewayrestartrequired = "gatewayRestartRequired"
case status
case error
}
@@ -357,6 +357,8 @@ export const SystemAgentSetupActivateResultSchema = closedObject({
latencyMs: Type.Optional(Type.Number()),
/** Human-readable setup summary lines (workspace, model, gateway). */
lines: Type.Optional(Type.Array(Type.String())),
/** The committed plugin source requires clients to reconnect before continuing. */
gatewayRestartRequired: Type.Optional(Type.Literal(true)),
/** Present on failure: coarse bucket for client copy + docs links. */
status: Type.Optional(SetupInferenceStatus),
error: Type.Optional(Type.String()),
+125
View File
@@ -238,6 +238,131 @@ describe("runRemoteGatewayInferenceOnboarding", () => {
},
);
it.each([
{
label: "request rejection",
firstVerification: () =>
Promise.reject(
Object.assign(new Error("gateway restarting"), {
name: "GatewayClientRequestError",
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 0,
}),
),
},
{
label: "typed unavailable result",
firstVerification: () =>
Promise.resolve({ ok: false, status: "unavailable", error: "gateway restarting" }),
},
])("waits for a declared Gateway restart after $label", async ({ firstVerification }) => {
const methods: string[] = [];
let verifyAttempts = 0;
const callGatewayMock = vi.fn(async (options: CallGatewayCliOptions): Promise<unknown> => {
methods.push(options.method);
if (options.method === "openclaw.setup.detect") {
return detectResult();
}
if (options.method === "openclaw.setup.activate") {
return {
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 250,
lines: ["Default model: openai/gpt-5.5"],
gatewayRestartRequired: true,
};
}
if (options.method === "openclaw.setup.verify" && verifyAttempts++ === 0) {
return await firstVerification();
}
if (options.method === "openclaw.setup.verify") {
return { ok: true, modelRef: "openai/gpt-5.5", latencyMs: 100 };
}
throw new Error(`unexpected Gateway method ${options.method}`);
});
const runGuidedOnboarding: RunGuidedOnboarding = async (_opts, runtime, deps) => {
const detection = await deps?.detect?.();
const candidate = detection?.candidates.find((entry) => entry.kind === "codex-cli");
if (!candidate) {
throw new Error("Codex candidate missing");
}
const activation = await deps?.activate?.({
kind: "codex-cli",
modelRef: candidate.modelRef,
surface: "cli",
runtime,
});
expect(activation).toMatchObject({ ok: true, gatewayRestartRequired: true });
};
await runRemoteGatewayInferenceOnboarding(
makeTarget(makeLocalConfig(), { token: "selected-token" }),
makeRuntime(),
{
callGateway: asGatewayCall(callGatewayMock),
runGuidedOnboarding,
},
);
expect(methods).toEqual([
"openclaw.setup.detect",
"openclaw.setup.activate",
"openclaw.setup.verify",
"openclaw.setup.verify",
]);
});
it("bounds a late restart verification call by the remaining deadline", async () => {
const now = vi.spyOn(Date, "now").mockReturnValue(45_500).mockReturnValueOnce(1_000);
const callGatewayMock = vi.fn(async (options: CallGatewayCliOptions): Promise<unknown> => {
if (options.method === "openclaw.setup.detect") {
return detectResult();
}
if (options.method === "openclaw.setup.activate") {
return {
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 250,
lines: ["Default model: openai/gpt-5.5"],
gatewayRestartRequired: true,
};
}
if (options.method === "openclaw.setup.verify") {
return { ok: true, modelRef: "openai/gpt-5.5", latencyMs: 100 };
}
throw new Error(`unexpected Gateway method ${options.method}`);
});
const runGuidedOnboarding: RunGuidedOnboarding = async (_opts, runtime, deps) => {
await deps?.detect?.();
await deps?.activate?.({
kind: "codex-cli",
modelRef: "openai/gpt-5.5",
surface: "cli",
runtime,
});
};
try {
await runRemoteGatewayInferenceOnboarding(
makeTarget(makeLocalConfig(), { token: "selected-token" }),
makeRuntime(),
{
callGateway: asGatewayCall(callGatewayMock),
runGuidedOnboarding,
},
);
} finally {
now.mockRestore();
}
expect(
callGatewayMock.mock.calls.find(
([options]) => options.method === "openclaw.setup.verify",
)?.[0].timeoutMs,
).toBe(500);
});
it("hands an auth-free Gateway to the TUI as the exact bound route", async () => {
const callGatewayMock = vi.fn(async (options: CallGatewayCliOptions): Promise<unknown> => {
if (options.method === "openclaw.setup.detect") {
+54 -6
View File
@@ -1,5 +1,6 @@
// Remote-Gateway onboarding adapters keep inference detection and activation on the Gateway host.
import { randomUUID } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import type {
SystemAgentChatResult,
SystemAgentSetupActivateResult,
@@ -7,7 +8,11 @@ import type {
SystemAgentSetupVerifyResult,
} from "../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { CallGatewayCliOptions } from "../gateway/call.js";
import {
isGatewayClientRequestError,
isGatewayTransportError,
type CallGatewayCliOptions,
} from "../gateway/call.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type {
ActivateSetupInferenceParams,
@@ -24,6 +29,7 @@ const GATEWAY_SETUP_ACTIVATE_TIMEOUT_MS = 150_000;
const GATEWAY_CODEX_SETUP_ACTIVATE_TIMEOUT_MS = 480_000;
const GATEWAY_SETUP_VERIFY_TIMEOUT_MS = 30_000;
const GATEWAY_SYSTEM_AGENT_CHAT_TIMEOUT_MS = 190_000;
const GATEWAY_RESTART_WAIT_TIMEOUT_MS = 45_000;
type CallGateway = <T>(options: CallGatewayCliOptions) => Promise<T>;
@@ -137,6 +143,7 @@ function toSetupInferenceActivationResult(
modelRef: result.modelRef,
latencyMs: result.latencyMs,
lines: result.lines,
...(result.gatewayRestartRequired ? { gatewayRestartRequired: true } : {}),
};
}
if (!isSetupInferenceFailureStatus(result.status) || !result.error?.trim()) {
@@ -255,11 +262,52 @@ export async function runRemoteGatewayInferenceOnboarding(
if (!activation.ok) {
return activation;
}
const verification = await request<SystemAgentSetupVerifyResult>({
method: "openclaw.setup.verify",
payload: {},
timeoutMs: GATEWAY_SETUP_VERIFY_TIMEOUT_MS,
});
const restartDeadline = Date.now() + GATEWAY_RESTART_WAIT_TIMEOUT_MS;
let retryDelayMs = 250;
let verification: SystemAgentSetupVerifyResult | undefined;
for (;;) {
const remainingBeforeAttemptMs = restartDeadline - Date.now();
if (activation.gatewayRestartRequired === true && remainingBeforeAttemptMs <= 0) {
break;
}
try {
verification = await request<SystemAgentSetupVerifyResult>({
method: "openclaw.setup.verify",
payload: {},
timeoutMs:
activation.gatewayRestartRequired === true
? Math.min(GATEWAY_SETUP_VERIFY_TIMEOUT_MS, remainingBeforeAttemptMs)
: GATEWAY_SETUP_VERIFY_TIMEOUT_MS,
});
const retryableResult =
activation.gatewayRestartRequired === true &&
!verification.ok &&
verification.status === "unavailable";
const remainingMs = restartDeadline - Date.now();
if (!retryableResult || remainingMs <= 0) {
break;
}
await delay(Math.min(retryDelayMs, remainingMs));
retryDelayMs = Math.min(retryDelayMs * 2, 2_000);
} catch (error) {
const retryable =
activation.gatewayRestartRequired === true &&
(isGatewayTransportError(error) ||
(isGatewayClientRequestError(error) && error.retryable));
const remainingMs = restartDeadline - Date.now();
if (!retryable || remainingMs <= 0) {
throw error;
}
const requestedDelay = isGatewayClientRequestError(error)
? (error.retryAfterMs ?? retryDelayMs)
: retryDelayMs;
await delay(Math.min(requestedDelay, remainingMs));
retryDelayMs = Math.min(retryDelayMs * 2, 2_000);
}
}
if (!verification) {
throw new Error("Gateway did not finish restarting before inference verification.");
}
assertVerifiedActivation({
activation,
verification,
@@ -48,6 +48,7 @@ export type SetupInferenceActivationPersistenceState = {
committedConfig: OpenClawConfig | undefined;
autoLocalModelLeanApplied: boolean;
codexInstallOwnership: "unknown" | "owned" | "unowned";
gatewayRestartRequired: boolean;
};
export async function persistActivatedSetupInference(input: {
@@ -228,9 +229,8 @@ export async function persistActivatedSetupInference(input: {
base: "source",
// The transform stays side-effect free so a config conflict can retry
// without replaying credential writes in another agent directory.
// 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" },
// The install-record owner adds a restart follow-up when this commit adopts
// a new plugin source. Preserve that intent for structured setup clients.
transform: async (current, context) => {
const latestRuntime = context.snapshot.runtimeConfig ?? context.snapshot.config;
// Validate that the candidate is still admissible before reporting
@@ -316,6 +316,7 @@ export async function persistActivatedSetupInference(input: {
},
});
committedConfig = committed.nextConfig;
state.gatewayRestartRequired = committed.followUp.requiresRestart;
if (pendingCodexInstall) {
codexInstallOwnership = "owned";
}
@@ -368,6 +369,7 @@ export async function persistActivatedSetupInference(input: {
throw error;
}
committedConfig = reconciledSnapshot?.sourceConfig ?? reconciledRuntime;
state.gatewayRestartRequired = pendingCodexInstall !== undefined;
setupInferenceLog.warn(
"Inference activation committed successfully despite a post-write cleanup error.",
);
+11 -1
View File
@@ -527,6 +527,7 @@ async function activateSetupInferenceUnredacted(
}
let committedConfig: OpenClawConfig | undefined;
let autoLocalModelLeanApplied = false;
let gatewayRestartRequired = false;
if (!needsPersistence) {
const latestSnapshot = await readSnapshot();
const latestRuntime =
@@ -571,6 +572,7 @@ async function activateSetupInferenceUnredacted(
committedConfig,
autoLocalModelLeanApplied,
codexInstallOwnership,
gatewayRestartRequired,
};
const persistenceFailure = await persistActivatedSetupInference({
params,
@@ -597,7 +599,12 @@ async function activateSetupInferenceUnredacted(
if (persistenceFailure) {
return persistenceFailure;
}
({ committedConfig, autoLocalModelLeanApplied, codexInstallOwnership } = persistenceState);
({
committedConfig,
autoLocalModelLeanApplied,
codexInstallOwnership,
gatewayRestartRequired,
} = persistenceState);
}
if (codexRegistryNeedsReload && committedConfig) {
const reloadedRuntimeConfig = await reloadCodexRegistryAfterActivation({
@@ -663,6 +670,9 @@ async function activateSetupInferenceUnredacted(
modelRef: plan.modelRef,
latencyMs: test.latencyMs,
lines,
...(params.surface === "gateway" && gatewayRestartRequired
? { gatewayRestartRequired: true as const }
: {}),
};
} finally {
let codexCleanupError: SetupInferenceActivationIndeterminateError | undefined;
+7 -1
View File
@@ -121,7 +121,13 @@ export type SetupInferenceStatus =
export type SetupInferenceFailureStatus = Exclude<SetupInferenceStatus, "ok">;
export type ActivateSetupInferenceResult =
| { ok: true; modelRef: string; latencyMs: number; lines: string[] }
| {
ok: true;
modelRef: string;
latencyMs: number;
lines: string[];
gatewayRestartRequired?: true;
}
| { ok: false; status: SetupInferenceFailureStatus; error: string };
/**
+22 -9
View File
@@ -586,7 +586,10 @@ function createConfigTransformHarness(
});
state.sourceConfig = withoutPluginInstallRecords(transformed.nextConfig);
state.runtimeConfig = materializeRuntimeAgentListForTest(state.sourceConfig);
return { nextConfig: state.sourceConfig };
return {
nextConfig: state.sourceConfig,
followUp: { mode: "auto", requiresRestart: false },
};
});
const readSnapshot = vi.fn(async () => ({
exists: true as const,
@@ -1345,7 +1348,10 @@ async function runCodexSetupWithFinalConfig(params: {
});
persistedConfig = withoutPluginInstallRecords(transformed.nextConfig);
committed = true;
return { nextConfig: persistedConfig };
return {
nextConfig: persistedConfig,
followUp: { mode: "auto", requiresRestart: false },
};
});
const readConfigFileSnapshot = vi.fn(async () => {
const runtimeConfig = committed ? persistedConfig : initialConfig;
@@ -4185,7 +4191,10 @@ describe("activateSetupInference", () => {
pendingCodexInstalls.push(transformed.plugins?.installs?.codex);
persistedConfig = withoutPluginInstallRecords(transformed);
activationCommitted = true;
return { nextConfig: persistedConfig };
return {
nextConfig: persistedConfig,
followUp: { mode: "restart", reason: "plugin source changed", requiresRestart: true },
};
},
);
const refreshPluginRegistry = vi.fn(async () => {
@@ -4232,7 +4241,7 @@ describe("activateSetupInference", () => {
ensurePluginRegistryLoaded: ensureRegistryLoaded,
},
});
expect(result.ok).toBe(true);
expect(result).toMatchObject({ ok: true, gatewayRestartRequired: true });
expect(runtimeLog).not.toHaveBeenCalled();
expect(ensureCodex).toHaveBeenCalledOnce();
expect(ensureCodex).toHaveBeenCalledWith(
@@ -4301,9 +4310,7 @@ describe("activateSetupInference", () => {
"reload-active-registry",
]);
expect(transformConfig).toHaveBeenCalledWith(
expect.objectContaining({
afterWrite: { mode: "auto" },
}),
expect.not.objectContaining({ afterWrite: expect.anything() }),
);
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: persistedConfig,
@@ -4668,7 +4675,10 @@ describe("activateSetupInference", () => {
pendingInstallRecords.push(pending);
installIndex = { ...installIndex, ...pending };
persistedConfig = withoutPluginInstallRecords(transformed);
return { nextConfig: persistedConfig };
return {
nextConfig: persistedConfig,
followUp: { mode: "auto", requiresRestart: false },
};
},
);
@@ -5040,7 +5050,10 @@ describe("activateSetupInference", () => {
committedInstallRecords.push(record);
}
currentConfig = withoutPluginInstallRecords(transformed.nextConfig);
return { nextConfig: currentConfig };
return {
nextConfig: currentConfig,
followUp: { mode: "auto", requiresRestart: false },
};
},
);
const readConfigFileSnapshot = vi.fn(async () => {