fix(update): use post-doctor plugin records

This commit is contained in:
Vincent Koc
2026-05-17 20:27:31 +08:00
parent 22723b6f1e
commit c80cb5986f
6 changed files with 105 additions and 5 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
- Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing `thought_signature` 400s. Fixes #72879. (#80358) Thanks @abnershang.

View File

@@ -1176,6 +1176,44 @@ describe("update-cli", () => {
expect(updateCall?.skipIds?.has("demo")).toBe(true);
});
it("post-core resume mode prefers post-doctor disk install records over the stale parent snapshot", async () => {
const resultDir = createCaseDir("openclaw-post-core-disk-records");
const recordsPath = path.join(resultDir, "plugin-install-records.json");
await fs.mkdir(resultDir, { recursive: true });
await fs.writeFile(
recordsPath,
`${JSON.stringify({
stale: {
source: "npm",
spec: "@openclaw/stale@1.0.0",
installPath: "/tmp/stale-plugin",
},
})}\n`,
"utf-8",
);
const postDoctorRecords = {
codex: {
source: "npm",
spec: "@openclaw/codex@2026.5.17",
installPath: "/tmp/codex-plugin",
},
} satisfies Record<string, PluginInstallRecord>;
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords);
await withEnvAsync(
{
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
OPENCLAW_UPDATE_POST_CORE_INSTALL_RECORDS_PATH: recordsPath,
},
async () => {
await updateCommand({ json: true, restart: false });
},
);
expect(syncPluginCall()?.config?.plugins?.installs).toEqual(postDoctorRecords);
});
it("post-core resume mode persists the requested update channel with the updated process", async () => {
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,

View File

@@ -9,7 +9,10 @@ import {
ensureCompletionCacheExists,
} from "../../commands/doctor-completion.js";
import { doctorCommand } from "../../commands/doctor.js";
import { UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV } from "../../commands/doctor/shared/update-phase.js";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
} from "../../commands/doctor/shared/update-phase.js";
import { createPreUpdateConfigSnapshot } from "../../config/backup-rotation.js";
import {
assertConfigWriteAllowedInCurrentMode,
@@ -144,8 +147,6 @@ const POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_SOURC
const POST_CORE_UPDATE_STARTED_AT_ENV = "OPENCLAW_UPDATE_POST_CORE_STARTED_AT_MS";
const POST_CORE_UPDATE_RESULT_POLL_MS = 100;
const PRE_UPDATE_CONFIG_SNAPSHOT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
const SERVICE_REFRESH_PATH_ENV_KEYS = [
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
@@ -2733,6 +2734,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
postCoreConfigSnapshot,
preUpdateSourceConfig,
);
const parentPluginInstallRecords =
await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath);
// The updated doctor may have repaired plugin installs before this fresh process resumed.
const currentPluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
const pluginInstallRecords =
Object.keys(currentPluginInstallRecords).length > 0
? currentPluginInstallRecords
: parentPluginInstallRecords;
const pluginUpdate = await runPostCorePluginUpdate({
root,
@@ -2742,7 +2751,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
restoredAuthoredChannels: restoredPostCoreConfig.authoredChannels,
opts,
timeoutMs: updateStepTimeoutMs,
pluginInstallRecords: await readPostCorePluginInstallRecordsFile(postCoreInstallRecordsPath),
pluginInstallRecords,
});
if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
await writePostCorePluginUpdateResultFile(

View File

@@ -374,6 +374,42 @@ describe("configured plugin install release step", () => {
});
});
it("defers package-manager plugin repair when an older updater supports post-doctor config writes", async () => {
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
changes: [],
warnings: [],
});
const { maybeRunConfiguredPluginInstallReleaseStep } =
await import("./release-configured-plugin-installs.js");
const result = await maybeRunConfiguredPluginInstallReleaseStep({
cfg: {
plugins: {
entries: {
discord: { enabled: true },
},
},
},
currentVersion: "2026.5.2-beta.1",
touchedVersion: "2026.5.1",
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
},
});
expect(readOnlyMissingPluginInstallRepairCall().env).toEqual({
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
});
expect(result).toEqual({
changes: [],
warnings: [],
completed: false,
touchedConfig: false,
});
});
it("repairs missing configured installs even when a prior update doctor touched config", async () => {
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
changes: ['Installed missing configured plugin "discord".'],

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
UPDATE_IN_PROGRESS_ENV,
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
UPDATE_POST_CORE_CONVERGENCE_ENV,
isLegacyPackageUpdateDoctorPass,
isPostCoreConvergencePass,
@@ -46,6 +47,12 @@ describe("update-phase env helpers", () => {
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
}),
).toBe(true);
expect(
shouldDeferConfiguredPluginInstallRepair({
[UPDATE_IN_PROGRESS_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
}),
).toBe(true);
expect(
shouldDeferConfiguredPluginInstallRepair({
[UPDATE_IN_PROGRESS_ENV]: "1",
@@ -67,6 +74,12 @@ describe("update-phase env helpers", () => {
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
}),
).toBe(false);
expect(
isLegacyPackageUpdateDoctorPass({
[UPDATE_IN_PROGRESS_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
}),
).toBe(false);
expect(
isLegacyPackageUpdateDoctorPass({
[UPDATE_IN_PROGRESS_ENV]: "1",

View File

@@ -4,6 +4,8 @@ export const UPDATE_IN_PROGRESS_ENV = "OPENCLAW_UPDATE_IN_PROGRESS";
export const UPDATE_POST_CORE_CONVERGENCE_ENV = "OPENCLAW_UPDATE_POST_CORE_CONVERGENCE";
export const UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV =
"OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR";
export const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
/**
* True iff the caller is the doctor pass that runs WHILE the core package
@@ -45,7 +47,8 @@ export function isUpdatePackageSwapInProgress(env: NodeJS.ProcessEnv): boolean {
export function shouldDeferConfiguredPluginInstallRepair(env: NodeJS.ProcessEnv): boolean {
return (
isUpdatePackageSwapInProgress(env) &&
isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV])
(isTruthyEnvValue(env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]) ||
isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]))
);
}