
























@@ -275,6 +275,8 @@ const {
275275} = await import("../config/config.js");
276276const { checkUpdateStatus, fetchNpmPackageTargetStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
277277await import("../infra/update-check.js");
278+const { CONTROL_PLANE_UPDATE_SENTINEL_META_ENV } =
279+await import("../infra/update-control-plane-sentinel.js");
278280const { runCommandWithTimeout } = await import("../process/exec.js");
279281const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js");
280282const { doctorCommand } = await import("../commands/doctor.js");
@@ -2146,6 +2148,85 @@ describe("update-cli", () => {
21462148).toBe("1");
21472149});
214821502151+it("runs package post-update doctor from the verified package root after a staged swap", async () => {
2152+const tempDir = await createTrackedTempDir("openclaw-update-staged-doctor-");
2153+const nodeModules = path.join(tempDir, "lib", "node_modules");
2154+const pkgRoot = path.join(nodeModules, "openclaw");
2155+const entryPath = path.join(pkgRoot, "dist", "index.js");
2156+mockPackageInstallStatus(pkgRoot);
2157+await fs.mkdir(path.dirname(entryPath), { recursive: true });
2158+await fs.writeFile(
2159+path.join(pkgRoot, "package.json"),
2160+JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
2161+"utf-8",
2162+);
2163+await fs.writeFile(entryPath, "export {};\n", "utf-8");
2164+await writePackageDistInventory(pkgRoot);
2165+pathExists.mockImplementation(async (candidate: string) => {
2166+try {
2167+await fs.access(candidate);
2168+return true;
2169+} catch {
2170+return false;
2171+}
2172+});
2173+vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
2174+if (!Array.isArray(argv)) {
2175+return {
2176+stdout: "",
2177+stderr: "",
2178+code: 0,
2179+signal: null,
2180+killed: false,
2181+termination: "exit",
2182+};
2183+}
2184+if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
2185+return {
2186+stdout: `${nodeModules}\n`,
2187+stderr: "",
2188+code: 0,
2189+signal: null,
2190+killed: false,
2191+termination: "exit",
2192+};
2193+}
2194+if (argv[0] === "npm" && argv[1] === "i" && argv.includes("--prefix")) {
2195+const stagePrefix = argv[argv.indexOf("--prefix") + 1];
2196+const stagePackageRoot = path.join(
2197+requireValue(stagePrefix, "stage prefix"),
2198+"lib",
2199+"node_modules",
2200+"openclaw",
2201+);
2202+const stageEntryPath = path.join(stagePackageRoot, "dist", "index.js");
2203+await fs.mkdir(path.dirname(stageEntryPath), { recursive: true });
2204+await fs.writeFile(
2205+path.join(stagePackageRoot, "package.json"),
2206+JSON.stringify({ name: "openclaw", version: "2026.5.14" }),
2207+"utf-8",
2208+);
2209+await fs.writeFile(stageEntryPath, "export {};\n", "utf-8");
2210+await writePackageDistInventory(stagePackageRoot);
2211+}
2212+return {
2213+stdout: "",
2214+stderr: "",
2215+code: 0,
2216+signal: null,
2217+killed: false,
2218+termination: "exit",
2219+};
2220+});
2221+2222+await updateCommand({ yes: true });
2223+2224+const doctorCall = doctorCommandCall();
2225+expect(doctorCall?.[0].slice(1)).toEqual([entryPath, "doctor", "--non-interactive", "--fix"]);
2226+expect(doctorCall?.[1].cwd).toBe(pkgRoot);
2227+expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
2228+});
2229+21492230it("stops a running managed gateway before package replacement", async () => {
21502231const tempDir = await createTrackedTempDir("openclaw-update-stop-service-");
21512232const nodeModules = path.join(tempDir, "node_modules");
@@ -3176,6 +3257,152 @@ describe("update-cli", () => {
31763257expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
31773258});
317832593260+it("writes the control-plane update sentinel after managed package restart health passes", async () => {
3261+const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-");
3262+const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-");
3263+const metaPath = path.join(metaDir, "meta.json");
3264+await fs.writeFile(
3265+metaPath,
3266+JSON.stringify({
3267+version: 1,
3268+meta: {
3269+sessionKey: "agent:main:webchat:dm:user-123",
3270+deliveryContext: { channel: "webchat", to: "webchat:user-123", accountId: "default" },
3271+note: "Update requested from the agent.",
3272+continuationMessage: "Check the running version and finish the update report.",
3273+},
3274+}),
3275+);
3276+3277+const updatedRoot = createCaseDir("openclaw-updated-root");
3278+const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
3279+setupUpdatedRootRefresh({
3280+entrypoints: [updatedEntrypoint],
3281+gatewayUpdateImpl: async () =>
3282+makeOkUpdateResult({
3283+mode: "npm",
3284+root: updatedRoot,
3285+before: { version: "2026.4.23" },
3286+after: { version: "2026.4.24" },
3287+}),
3288+});
3289+serviceLoaded.mockResolvedValue(true);
3290+probeGateway.mockResolvedValue({
3291+ok: true,
3292+close: null,
3293+server: {
3294+version: "2026.4.24",
3295+connId: "updated-gateway",
3296+},
3297+auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
3298+health: null,
3299+status: null,
3300+presence: null,
3301+configSnapshot: null,
3302+connectLatencyMs: 1,
3303+error: null,
3304+url: "ws://127.0.0.1:18789",
3305+});
3306+3307+await withEnvAsync(
3308+{
3309+[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
3310+OPENCLAW_STATE_DIR: stateDir,
3311+},
3312+async () => {
3313+await updateCommand({ yes: true, json: true });
3314+},
3315+);
3316+3317+const raw = await fs.readFile(path.join(stateDir, "restart-sentinel.json"), "utf-8");
3318+const sentinel = JSON.parse(raw) as {
3319+payload?: {
3320+status?: string;
3321+message?: string | null;
3322+continuation?: { kind?: string; message?: string };
3323+stats?: { mode?: string; after?: { version?: string | null } };
3324+};
3325+};
3326+expect(sentinel.payload?.status).toBe("ok");
3327+expect(sentinel.payload?.message).toBe("Update requested from the agent.");
3328+expect(sentinel.payload?.continuation).toEqual({
3329+kind: "agentTurn",
3330+message: "Check the running version and finish the update report.",
3331+});
3332+expect(sentinel.payload?.stats?.mode).toBe("npm");
3333+expect(sentinel.payload?.stats?.after?.version).toBe("2026.4.24");
3334+});
3335+3336+it("marks the control-plane update sentinel failed when restart health verification fails", async () => {
3337+const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-");
3338+const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-");
3339+const metaPath = path.join(metaDir, "meta.json");
3340+await fs.writeFile(
3341+metaPath,
3342+JSON.stringify({
3343+version: 1,
3344+meta: {
3345+sessionKey: "agent:main:webchat:dm:user-123",
3346+continuationMessage: "This should not report a successful update.",
3347+},
3348+}),
3349+);
3350+3351+const updatedRoot = createCaseDir("openclaw-updated-root");
3352+const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
3353+setupUpdatedRootRefresh({
3354+entrypoints: [updatedEntrypoint],
3355+gatewayUpdateImpl: async () =>
3356+makeOkUpdateResult({
3357+mode: "npm",
3358+root: updatedRoot,
3359+before: { version: "2026.4.23" },
3360+after: { version: "2026.4.24" },
3361+}),
3362+});
3363+prepareRestartScript.mockResolvedValue(null);
3364+serviceLoaded.mockResolvedValue(true);
3365+probeGateway.mockResolvedValue({
3366+ok: true,
3367+close: null,
3368+server: {
3369+version: "2026.4.23",
3370+connId: "old-gateway",
3371+},
3372+auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" },
3373+health: null,
3374+status: null,
3375+presence: null,
3376+configSnapshot: null,
3377+connectLatencyMs: 1,
3378+error: null,
3379+url: "ws://127.0.0.1:18789",
3380+});
3381+3382+await withEnvAsync(
3383+{
3384+[CONTROL_PLANE_UPDATE_SENTINEL_META_ENV]: metaPath,
3385+OPENCLAW_STATE_DIR: stateDir,
3386+},
3387+async () => {
3388+await updateCommand({ yes: true, json: true });
3389+},
3390+);
3391+3392+const raw = await fs.readFile(path.join(stateDir, "restart-sentinel.json"), "utf-8");
3393+const sentinel = JSON.parse(raw) as {
3394+payload?: {
3395+status?: string;
3396+continuation?: unknown;
3397+stats?: { reason?: string | null };
3398+};
3399+};
3400+expect(sentinel.payload?.status).toBe("error");
3401+expect(sentinel.payload?.stats?.reason).toBe("restart-unhealthy");
3402+expect(sentinel.payload?.continuation).toBeUndefined();
3403+expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
3404+});
3405+31793406it("fails a package update when the restarted gateway reports activated plugin load errors", async () => {
31803407const updatedRoot = createCaseDir("openclaw-updated-root");
31813408const updatedEntrypoint = path.join(updatedRoot, "dist", "entry.js");
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。