Skip to content

Commit 862790f

Browse files
committed
fix: verify supervised update handoff in UI
1 parent a98c158 commit 862790f

9 files changed

Lines changed: 229 additions & 7 deletions

ui/src/ui/app-channels.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ function createHost(request: ReturnType<typeof vi.fn> = vi.fn()): ChannelsAction
7373
nostrProfileAccountId: null,
7474
nostrProfileFormState: null,
7575
pendingUpdateExpectedVersion: null,
76+
pendingUpdateHandoff: false,
7677
settings: {},
7778
updateStatusBanner: null,
7879
updateRunning: false,

ui/src/ui/app-gateway-chat-load.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ function createHost(tab: Tab) {
209209
assistantAgentId: null,
210210
serverVersion: null,
211211
pendingUpdateExpectedVersion: null,
212+
pendingUpdateHandoff: false,
212213
updateStatusBanner: null,
213214
sessionKey: "main",
214215
chatRunId: null,

ui/src/ui/app-gateway.node.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ function createHost(): TestGatewayHost {
185185
localMediaPreviewRoots: [],
186186
serverVersion: null,
187187
pendingUpdateExpectedVersion: null,
188+
pendingUpdateHandoff: false,
188189
updateStatusBanner: null,
189190
sessionKey: "main",
190191
chatMessages: [],
@@ -532,6 +533,92 @@ describe("connectGateway", () => {
532533
await vi.waitFor(() => {
533534
expect(host.pendingUpdateExpectedVersion).toBeNull();
534535
});
536+
expect(host.pendingUpdateHandoff).toBe(false);
537+
expect(host.updateStatusBanner).toBeNull();
538+
});
539+
540+
it("clears managed-service handoff verification when update status completes", async () => {
541+
const host = createHost();
542+
host.pendingUpdateHandoff = true;
543+
544+
connectGateway(host);
545+
const client = requireGatewayClient();
546+
client.request.mockImplementation(async (method: string) => {
547+
if (method === "update.status") {
548+
return {
549+
sentinel: {
550+
kind: "update",
551+
status: "ok",
552+
stats: {
553+
after: { version: "2.0.0" },
554+
},
555+
},
556+
};
557+
}
558+
return {};
559+
});
560+
561+
client.emitHello({
562+
type: "hello-ok",
563+
protocol: 4,
564+
server: { version: "2.0.0" },
565+
auth: { role: "operator", scopes: [] },
566+
snapshot: {},
567+
});
568+
569+
await vi.waitFor(() => {
570+
expect(host.pendingUpdateHandoff).toBe(false);
571+
});
572+
expect(host.pendingUpdateExpectedVersion).toBeNull();
573+
expect(host.updateStatusBanner).toBeNull();
574+
});
575+
576+
it("keeps polling while managed-service handoff restart health is pending", async () => {
577+
const host = createHost();
578+
host.pendingUpdateHandoff = true;
579+
580+
connectGateway(host);
581+
const client = requireGatewayClient();
582+
let updateStatusCalls = 0;
583+
client.request.mockImplementation(async (method: string) => {
584+
if (method === "update.status") {
585+
updateStatusCalls += 1;
586+
if (updateStatusCalls === 1) {
587+
return {
588+
sentinel: {
589+
kind: "update",
590+
status: "skipped",
591+
stats: {
592+
reason: "restart-health-pending",
593+
},
594+
},
595+
};
596+
}
597+
return {
598+
sentinel: {
599+
kind: "update",
600+
status: "ok",
601+
stats: {
602+
after: { version: "2.0.0" },
603+
},
604+
},
605+
};
606+
}
607+
return {};
608+
});
609+
610+
client.emitHello({
611+
type: "hello-ok",
612+
protocol: 4,
613+
server: { version: "2.0.0" },
614+
auth: { role: "operator", scopes: [] },
615+
snapshot: {},
616+
});
617+
618+
await vi.waitFor(() => {
619+
expect(updateStatusCalls).toBeGreaterThanOrEqual(2);
620+
expect(host.pendingUpdateHandoff).toBe(false);
621+
});
535622
expect(host.updateStatusBanner).toBeNull();
536623
});
537624

@@ -566,6 +653,7 @@ describe("connectGateway", () => {
566653

567654
await vi.waitFor(() => {
568655
expect(host.pendingUpdateExpectedVersion).toBeNull();
656+
expect(host.pendingUpdateHandoff).toBe(false);
569657
expect(host.updateStatusBanner).toEqual({
570658
tone: "danger",
571659
text: "Update installed but running version did not change — restart may have been blocked. Expected v2.0.0, running v1.0.0.",
@@ -605,13 +693,52 @@ describe("connectGateway", () => {
605693

606694
await vi.waitFor(() => {
607695
expect(host.pendingUpdateExpectedVersion).toBeNull();
696+
expect(host.pendingUpdateHandoff).toBe(false);
608697
expect(host.updateStatusBanner).toEqual({
609698
tone: "danger",
610699
text: "Update error: restart-unhealthy. The replacement process never became healthy and the previous process stayed up.",
611700
});
612701
});
613702
});
614703

704+
it("surfaces managed-service handoff failures even without a final version", async () => {
705+
const host = createHost();
706+
host.pendingUpdateHandoff = true;
707+
708+
connectGateway(host);
709+
const client = requireGatewayClient();
710+
client.request.mockImplementation(async (method: string) => {
711+
if (method === "update.status") {
712+
return {
713+
sentinel: {
714+
kind: "update",
715+
status: "error",
716+
stats: {
717+
reason: "managed-service-handoff-failed",
718+
},
719+
},
720+
};
721+
}
722+
return {};
723+
});
724+
725+
client.emitHello({
726+
type: "hello-ok",
727+
protocol: 4,
728+
server: { version: "1.0.0" },
729+
auth: { role: "operator", scopes: [] },
730+
snapshot: {},
731+
});
732+
733+
await vi.waitFor(() => {
734+
expect(host.pendingUpdateHandoff).toBe(false);
735+
expect(host.updateStatusBanner).toEqual({
736+
tone: "danger",
737+
text: "Update error: managed-service-handoff-failed. Check the gateway logs for the replacement failure.",
738+
});
739+
});
740+
});
741+
615742
it("ignores stale client onClose callbacks after reconnect", () => {
616743
const host = createHost();
617744

ui/src/ui/app-gateway.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ type GatewayHost = {
132132
assistantAgentId: string | null;
133133
serverVersion: string | null;
134134
pendingUpdateExpectedVersion: string | null;
135+
pendingUpdateHandoff: boolean;
135136
updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null;
136137
sessionKey: string;
137138
sessionsShowArchived: boolean;
@@ -179,6 +180,12 @@ type GatewayHostWithSideResults = GatewayHost & {
179180
const SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS = 5_000;
180181
const DEFERRED_SESSION_MESSAGE_REPLAY_POLL_MS = 250;
181182
const DEFERRED_SESSION_MESSAGE_REPLAY_TIMEOUT_MS = 10_000;
183+
const UPDATE_HANDOFF_STARTED_REASON = "managed-service-handoff-started";
184+
const UPDATE_RESTART_HEALTH_PENDING_REASON = "restart-health-pending";
185+
const PENDING_UPDATE_HANDOFF_REASONS = new Set([
186+
UPDATE_HANDOFF_STARTED_REASON,
187+
UPDATE_RESTART_HEALTH_PENDING_REASON,
188+
]);
182189

183190
function enqueueApprovalRequest(host: GatewayHost, entry: ExecApprovalRequest | null) {
184191
if (!entry) {
@@ -281,12 +288,35 @@ function resolvePostRestartUpdateBanner(reason: string | null | undefined): {
281288
};
282289
}
283290

291+
function resolvePendingUpdateHandoffTimeoutBanner(): {
292+
tone: "danger";
293+
text: string;
294+
} {
295+
return {
296+
tone: "danger",
297+
text: "Update handoff started, but completion was not reported after reconnect. Run `openclaw update status` for the final result.",
298+
};
299+
}
300+
301+
function isPendingUpdateHandoffSentinel(
302+
sentinel: UpdateRestartStatusResponse["sentinel"],
303+
): boolean {
304+
const reason = sentinel?.stats?.reason;
305+
return (
306+
sentinel?.kind === "update" &&
307+
sentinel.status === "skipped" &&
308+
typeof reason === "string" &&
309+
PENDING_UPDATE_HANDOFF_REASONS.has(reason)
310+
);
311+
}
312+
284313
async function verifyPendingUpdateVersion(
285314
host: GatewayHost,
286315
client: GatewayBrowserClient,
287316
): Promise<void> {
288317
const expectedVersion = host.pendingUpdateExpectedVersion?.trim();
289-
if (!expectedVersion) {
318+
const pendingHandoff = host.pendingUpdateHandoff;
319+
if (!expectedVersion && !pendingHandoff) {
290320
return;
291321
}
292322
const deadline = Date.now() + 10_000;
@@ -298,14 +328,33 @@ async function verifyPendingUpdateVersion(
298328
response = null;
299329
}
300330
const sentinel = response?.sentinel;
331+
if (isPendingUpdateHandoffSentinel(sentinel)) {
332+
await new Promise<void>((resolve) => {
333+
setTimeout(resolve, 250);
334+
});
335+
continue;
336+
}
337+
if (sentinel?.kind === "update" && sentinel.status && sentinel.status !== "ok") {
338+
host.pendingUpdateExpectedVersion = null;
339+
host.pendingUpdateHandoff = false;
340+
host.updateStatusBanner = resolvePostRestartUpdateBanner(sentinel.stats?.reason ?? null);
341+
return;
342+
}
301343
const actualVersion = sentinel?.stats?.after?.version?.trim() || null;
344+
if (
345+
sentinel?.kind === "update" &&
346+
sentinel.status === "ok" &&
347+
!actualVersion &&
348+
!expectedVersion
349+
) {
350+
host.pendingUpdateExpectedVersion = null;
351+
host.pendingUpdateHandoff = false;
352+
return;
353+
}
302354
if (sentinel?.kind === "update" && actualVersion) {
303355
host.pendingUpdateExpectedVersion = null;
304-
if (sentinel.status && sentinel.status !== "ok") {
305-
host.updateStatusBanner = resolvePostRestartUpdateBanner(sentinel.stats?.reason ?? null);
306-
return;
307-
}
308-
if (actualVersion !== expectedVersion) {
356+
host.pendingUpdateHandoff = false;
357+
if (expectedVersion && actualVersion !== expectedVersion) {
309358
host.updateStatusBanner = resolveUpdateVerificationBanner({
310359
expectedVersion,
311360
actualVersion,
@@ -322,11 +371,14 @@ async function verifyPendingUpdateVersion(
322371
}
323372
const currentVersion = host.hello?.server?.version?.trim() || null;
324373
host.pendingUpdateExpectedVersion = null;
325-
if (currentVersion !== expectedVersion) {
374+
host.pendingUpdateHandoff = false;
375+
if (expectedVersion && currentVersion !== expectedVersion) {
326376
host.updateStatusBanner = resolveUpdateVerificationBanner({
327377
expectedVersion,
328378
actualVersion: currentVersion,
329379
});
380+
} else if (pendingHandoff) {
381+
host.updateStatusBanner = resolvePendingUpdateHandoffTimeoutBanner();
330382
}
331383
}
332384

ui/src/ui/app-view-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export type AppViewState = {
234234
configActiveSection: string | null;
235235
configActiveSubsection: string | null;
236236
pendingUpdateExpectedVersion: string | null;
237+
pendingUpdateHandoff: boolean;
237238
updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null;
238239
communicationsFormMode: "form" | "raw";
239240
communicationsSearchQuery: string;

ui/src/ui/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ export class OpenClawApp extends LitElement {
404404
@state() configActiveSection: string | null = null;
405405
@state() configActiveSubsection: string | null = null;
406406
@state() pendingUpdateExpectedVersion: string | null = null;
407+
@state() pendingUpdateHandoff = false;
407408
@state() updateStatusBanner: { tone: "danger" | "warn" | "info"; text: string } | null = null;
408409
@state() communicationsFormMode: "form" | "raw" = "form";
409410
@state() communicationsSearchQuery = "";

ui/src/ui/controllers/agents.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ function createSaveState(): {
7777
configActiveSection: null,
7878
configActiveSubsection: null,
7979
pendingUpdateExpectedVersion: null,
80+
pendingUpdateHandoff: false,
8081
updateStatusBanner: null,
8182
lastError: null,
8283
},

ui/src/ui/controllers/config.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function createState(): ConfigState {
4545
connected: false,
4646
lastError: null,
4747
pendingUpdateExpectedVersion: null,
48+
pendingUpdateHandoff: false,
4849
updateStatusBanner: null,
4950
updateRunning: false,
5051
};
@@ -1157,6 +1158,27 @@ describe("runUpdate", () => {
11571158
await runUpdate(state);
11581159

11591160
expect(state.pendingUpdateExpectedVersion).toBe("2.0.0");
1161+
expect(state.pendingUpdateHandoff).toBe(false);
1162+
expect(state.updateStatusBanner).toBeNull();
1163+
});
1164+
1165+
it("tracks managed-service handoff updates for reconnect verification", async () => {
1166+
const request = vi.fn().mockResolvedValue({
1167+
ok: true,
1168+
result: {
1169+
status: "skipped",
1170+
reason: "managed-service-handoff-started",
1171+
},
1172+
handoff: { status: "started" },
1173+
});
1174+
const state = createState();
1175+
state.connected = true;
1176+
state.client = { request } as unknown as ConfigState["client"];
1177+
1178+
await runUpdate(state);
1179+
1180+
expect(state.pendingUpdateExpectedVersion).toBeNull();
1181+
expect(state.pendingUpdateHandoff).toBe(true);
11601182
expect(state.updateStatusBanner).toBeNull();
11611183
});
11621184
});

0 commit comments

Comments
 (0)