Skip to content

Commit 056c4e9

Browse files
authored
Merge branch 'main' into codex/fix-9604-wsl-ollama-probe
2 parents 96a2e5b + b7383ca commit 056c4e9

36 files changed

Lines changed: 2100 additions & 145 deletions

src/lib/actions/inference-set-gateway-restart.ts

Lines changed: 186 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,113 @@ import type { ConfigObject } from "../security/credential-filter";
66
import type { ShieldsAuditEntry } from "../shields/audit";
77
import { type InferenceApi, readOpenClawPrimaryRouteApi } from "./inference-route-api";
88
import { InferenceSetError } from "./inference-set-error";
9+
import {
10+
runPortableOpenClawPairingApproval,
11+
runPortableOpenClawPairingRequestProducer,
12+
type PortableOpenClawPairingApprovalReceipt,
13+
} from "./sandbox/auto-pair-approval";
914
import type { GatewayRestartResult } from "./sandbox/gateway-restart";
15+
import {
16+
observeOpenClawPairingSettlement,
17+
type OpenClawPairingSettlementObservation,
18+
} from "./sandbox/launch-readiness/openclaw-pairing-qualification";
19+
20+
export type InferenceSetOpenClawPairingTarget = {
21+
readonly sandboxName: string;
22+
readonly gatewayName: string;
23+
readonly openclawVersion: string;
24+
readonly stateDirectory: string;
25+
};
26+
27+
export type InferenceSetOpenClawPairingFailureLayer =
28+
| "initial-state-unavailable"
29+
| "final-state-unavailable"
30+
| "final-state-unsettled"
31+
| "pairing-operation-failed"
32+
| "pairing-target-unavailable"
33+
| `approval-${Exclude<PortableOpenClawPairingApprovalReceipt, "approved">}`;
34+
35+
export type InferenceSetOpenClawPairingResult =
36+
| { readonly ok: true }
37+
| { readonly ok: false; readonly failureLayer: InferenceSetOpenClawPairingFailureLayer };
38+
39+
export type InferenceSetOpenClawPairingDeps = {
40+
observePairing: (
41+
target: InferenceSetOpenClawPairingTarget,
42+
) => OpenClawPairingSettlementObservation;
43+
publishScopeRequest: (target: InferenceSetOpenClawPairingTarget) => void;
44+
approveScopeRequest: (
45+
target: InferenceSetOpenClawPairingTarget,
46+
deviceIdentitySha256: string,
47+
) => PortableOpenClawPairingApprovalReceipt;
48+
};
49+
50+
const defaultOpenClawPairingDeps: InferenceSetOpenClawPairingDeps = {
51+
observePairing: (target) =>
52+
observeOpenClawPairingSettlement(
53+
target.sandboxName,
54+
target.gatewayName,
55+
target.openclawVersion,
56+
target.stateDirectory,
57+
),
58+
publishScopeRequest: (target) =>
59+
runPortableOpenClawPairingRequestProducer(target.sandboxName, target.gatewayName),
60+
approveScopeRequest: (target, deviceIdentitySha256) =>
61+
runPortableOpenClawPairingApproval(
62+
target.sandboxName,
63+
target.gatewayName,
64+
deviceIdentitySha256,
65+
),
66+
};
67+
68+
/**
69+
* Reconcile the local OpenClaw CLI device after an inference route change.
70+
*
71+
* The state observer accepts only the exact operator pairing/read/write projection.
72+
* The request producer runs only for a pairing-only device. The approval helper then
73+
* binds one allowlisted request to the observed device identity. A final state read,
74+
* not command output, decides whether the inference switch can report success.
75+
*/
76+
export function settleInferenceSetOpenClawPairing(
77+
target: InferenceSetOpenClawPairingTarget,
78+
deps: InferenceSetOpenClawPairingDeps = defaultOpenClawPairingDeps,
79+
): InferenceSetOpenClawPairingResult {
80+
let initial: OpenClawPairingSettlementObservation;
81+
try {
82+
initial = deps.observePairing(target);
83+
} catch {
84+
return { ok: false, failureLayer: "initial-state-unavailable" };
85+
}
86+
if (initial.state === "settled") return { ok: true };
87+
88+
let approval: PortableOpenClawPairingApprovalReceipt;
89+
try {
90+
deps.publishScopeRequest(target);
91+
approval = deps.approveScopeRequest(target, initial.deviceIdentitySha256);
92+
} catch {
93+
return { ok: false, failureLayer: "pairing-operation-failed" };
94+
}
95+
96+
let final: OpenClawPairingSettlementObservation;
97+
try {
98+
final = deps.observePairing(target);
99+
} catch {
100+
return { ok: false, failureLayer: "final-state-unavailable" };
101+
}
102+
if (final.state === "settled") return { ok: true };
103+
return {
104+
ok: false,
105+
failureLayer: approval === "approved" ? "final-state-unsettled" : `approval-${approval}`,
106+
};
107+
}
10108

11109
export interface InferenceGatewayRestartDeps {
12110
appendAuditEntry: (entry: ShieldsAuditEntry) => void;
13111
log: (message: string) => void;
14112
restartSandboxGateway: (sandboxName: string) => GatewayRestartResult;
113+
settleOpenClawPairing: (
114+
target: InferenceSetOpenClawPairingTarget,
115+
) => InferenceSetOpenClawPairingResult;
15116
}
16117

17118
interface InferenceResultForGateway {
@@ -33,17 +134,25 @@ interface InferenceResultForGateway {
33134
export interface InferenceMutation<T extends InferenceResultForGateway> {
34135
result: T;
35136
openClawGatewayRestartRequired: boolean;
137+
openClawPairing:
138+
| { readonly state: "not-required" }
139+
| { readonly state: "required"; readonly target: InferenceSetOpenClawPairingTarget }
140+
| { readonly state: "target-unavailable" };
36141
}
37142

38-
// SOURCE_OF_TRUTH_REVIEW (cross-family OpenClaw restart; gateway regression
39-
// #4504, OpenClaw 2026.6.10 adopted in #5595): that version hot-reloads model
143+
// SOURCE_OF_TRUTH_REVIEW (OpenClaw post-switch convergence; gateway regressions
144+
// #4504 and #9527): OpenClaw 2026.6.10 adopted in #5595 hot-reloads model
40145
// identity but retains request shaping when the API family changes. NemoClaw
41-
// therefore restarts only after the route, config, and integrity hash commit,
42-
// and outside the config transition lock. Unit coverage proves restart,
43-
// no-restart, redaction, audit-failure, and post-commit recovery behavior;
44-
// openclaw-inference-switch live coverage proves gateway health and forwarding.
45-
// Remove this coordination when the minimum supported OpenClaw hot-reloads
46-
// request shaping across API-family changes, keeping the tests until then.
146+
// restarts only after the route, config, and integrity hash commit. Every
147+
// changed OpenClaw route then requires exact local device-scope convergence
148+
// before the command reports success. Both operations run outside the config
149+
// transition lock and inside the sandbox lifecycle lock. Unit coverage proves
150+
// restart, no-restart, scope convergence, redaction, audit-failure, and
151+
// post-commit recovery behavior. The openclaw-inference-switch live target
152+
// proves gateway health and forwarding. Remove the restart when the minimum
153+
// supported OpenClaw hot-reloads request shaping across API-family changes.
154+
// Remove pairing settlement when OpenClaw no longer requires a separate
155+
// allowlisted device-scope upgrade after a route change.
47156

48157
export function defaultInferenceGatewayRestart(sandboxName: string): GatewayRestartResult {
49158
const recovery: typeof import("./sandbox/process-recovery") = require("./sandbox/process-recovery");
@@ -78,18 +187,21 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
78187
agentName: string;
79188
configChanged: boolean;
80189
nextApi: string;
190+
openClawPairingTarget?: InferenceSetOpenClawPairingTarget;
81191
previousApi: InferenceApi | null;
82192
result: T;
83193
},
84194
deps: Pick<InferenceGatewayRestartDeps, "appendAuditEntry" | "log">,
85195
): InferenceMutation<T> {
86-
const { agentName, configChanged, nextApi, previousApi, result } = options;
196+
const { agentName, configChanged, nextApi, openClawPairingTarget, previousApi, result } = options;
87197
const openClawGatewayRestartRequired =
88198
agentName === "openclaw" &&
89199
configChanged &&
90200
result.inSandboxConfigSynced &&
91201
previousApi !== null &&
92202
previousApi !== nextApi;
203+
const openClawPairingConvergenceRequired =
204+
agentName === "openclaw" && configChanged && result.inSandboxConfigSynced;
93205

94206
const auditEntry: ShieldsAuditEntry = {
95207
action: "inference_set",
@@ -99,11 +211,13 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
99211
!result.inSandboxConfigSynced
100212
? " (in-sandbox sync incomplete)"
101213
: openClawGatewayRestartRequired
102-
? " (gateway restart pending)"
103-
: ""
214+
? " (gateway restart and pairing convergence pending)"
215+
: openClawPairingConvergenceRequired
216+
? " (pairing convergence pending)"
217+
: ""
104218
}`,
105219
};
106-
if (openClawGatewayRestartRequired) {
220+
if (openClawGatewayRestartRequired || openClawPairingConvergenceRequired) {
107221
appendPostCommitInferenceAudit(deps, auditEntry);
108222
} else {
109223
deps.appendAuditEntry(auditEntry);
@@ -112,51 +226,92 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
112226
// A Hermes switch whose Web Dashboard profile did not converge is not fully
113227
// applied, so withhold the success line (the caller already warned) (#6893).
114228
const hermesDashboardStale = agentName === "hermes" && result.dashboardConverged === false;
115-
if (result.inSandboxConfigSynced && !openClawGatewayRestartRequired && !hermesDashboardStale) {
229+
if (
230+
result.inSandboxConfigSynced &&
231+
!openClawGatewayRestartRequired &&
232+
!openClawPairingConvergenceRequired &&
233+
!hermesDashboardStale
234+
) {
116235
deps.log(
117236
agentName === "hermes"
118237
? ` Inference route synced for '${result.sandboxName}': ${result.model}`
119238
: ` Inference route synced for '${result.sandboxName}': ${result.primaryModelRef}`,
120239
);
121240
}
122241

123-
return { result, openClawGatewayRestartRequired };
242+
return {
243+
result,
244+
openClawGatewayRestartRequired,
245+
openClawPairing: !openClawPairingConvergenceRequired
246+
? { state: "not-required" }
247+
: openClawPairingTarget
248+
? { state: "required", target: openClawPairingTarget }
249+
: { state: "target-unavailable" },
250+
};
124251
}
125252

126-
export function completeInferenceGatewayRestart<T extends InferenceResultForGateway>(
253+
export function completeInferencePostCommit<T extends InferenceResultForGateway>(
127254
mutation: InferenceMutation<T>,
128255
deps: InferenceGatewayRestartDeps,
129256
): void {
130-
if (!mutation.openClawGatewayRestartRequired) return;
131-
132257
const { result } = mutation;
133-
deps.log(
134-
` Restarting the OpenClaw gateway in '${result.sandboxName}' to apply the new inference API family...`,
135-
);
136-
let restartFailure: string | null = null;
137-
try {
138-
const restart = deps.restartSandboxGateway(result.sandboxName);
139-
if (!restart.ok) restartFailure = restart.failureLayer;
140-
} catch {
141-
restartFailure = "restart exception";
258+
if (mutation.openClawGatewayRestartRequired) {
259+
deps.log(
260+
` Restarting the OpenClaw gateway in '${result.sandboxName}' to apply the new inference API family...`,
261+
);
262+
let restartFailure: string | null = null;
263+
try {
264+
const restart = deps.restartSandboxGateway(result.sandboxName);
265+
if (!restart.ok) restartFailure = restart.failureLayer;
266+
} catch {
267+
restartFailure = "restart exception";
268+
}
269+
if (restartFailure) {
270+
appendPostCommitInferenceAudit(deps, {
271+
action: "inference_set",
272+
sandbox: result.sandboxName,
273+
timestamp: new Date().toISOString(),
274+
reason: `inference set openclaw:${result.provider}:${result.model} (config committed; gateway restart failed: ${restartFailure})`,
275+
});
276+
throw new InferenceSetError(
277+
`Inference route and config were updated for '${result.sandboxName}', but the managed OpenClaw gateway restart/recovery did not complete successfully. ` +
278+
`The committed route was not rolled back. Retry with '${CLI_NAME} ${result.sandboxName} gateway restart'.`,
279+
);
280+
}
281+
}
282+
const pairingMutation = mutation.openClawPairing;
283+
if (pairingMutation.state === "not-required") return;
284+
let pairing: InferenceSetOpenClawPairingResult;
285+
if (pairingMutation.state === "target-unavailable") {
286+
pairing = { ok: false, failureLayer: "pairing-target-unavailable" };
287+
} else {
288+
try {
289+
pairing = deps.settleOpenClawPairing(pairingMutation.target);
290+
} catch {
291+
pairing = { ok: false, failureLayer: "pairing-operation-failed" };
292+
}
142293
}
143-
if (restartFailure) {
294+
if (!pairing.ok) {
144295
appendPostCommitInferenceAudit(deps, {
145296
action: "inference_set",
146297
sandbox: result.sandboxName,
147298
timestamp: new Date().toISOString(),
148-
reason: `inference set openclaw:${result.provider}:${result.model} (config committed; gateway restart failed: ${restartFailure})`,
299+
reason: `inference set openclaw:${result.provider}:${result.model} (config committed; ${
300+
mutation.openClawGatewayRestartRequired ? "gateway restart completed; " : ""
301+
}pairing convergence failed: ${pairing.failureLayer})`,
149302
});
150303
throw new InferenceSetError(
151-
`Inference route and config were updated for '${result.sandboxName}', but the managed OpenClaw gateway restart/recovery did not complete successfully. ` +
152-
`The committed route was not rolled back. Retry with '${CLI_NAME} ${result.sandboxName} gateway restart'.`,
304+
`Inference route and config were updated for '${result.sandboxName}', but OpenClaw gateway pairing did not converge (${pairing.failureLayer}). ` +
305+
`The committed route was not rolled back. Run '${CLI_NAME} ${result.sandboxName} doctor --fix', then retry the agent turn.`,
153306
);
154307
}
155308
appendPostCommitInferenceAudit(deps, {
156309
action: "inference_set",
157310
sandbox: result.sandboxName,
158311
timestamp: new Date().toISOString(),
159-
reason: `inference set openclaw:${result.provider}:${result.model} (gateway restart completed)`,
312+
reason: `inference set openclaw:${result.provider}:${result.model} (${
313+
mutation.openClawGatewayRestartRequired ? "gateway restart and " : ""
314+
}pairing convergence completed)`,
160315
});
161316
deps.log(` Inference route synced for '${result.sandboxName}': ${result.primaryModelRef}`);
162317
}

0 commit comments

Comments
 (0)