Skip to content

Commit 718a733

Browse files
fix(update): expose plugin convergence repair
1 parent 9fdd56d commit 718a733

8 files changed

Lines changed: 121 additions & 46 deletions

docs/cli/update.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ updates happen via the package-manager flow in [Updating](/install/updating).
1919
```bash
2020
openclaw update
2121
openclaw update status
22+
openclaw update repair
2223
openclaw update wizard
2324
openclaw update --channel beta
2425
openclaw update --channel dev
@@ -76,6 +77,36 @@ Options:
7677
- `--json`: print machine-readable status JSON.
7778
- `--timeout <seconds>`: timeout for checks (default is 3s).
7879

80+
## `update repair`
81+
82+
Rerun update finalization after the core package already changed but later
83+
repair work did not finish cleanly. This is the supported recovery path when
84+
`openclaw update` installed the new core package but post-core plugin sync,
85+
managed npm plugin metadata, registry refresh, or doctor repair still needs to
86+
converge.
87+
88+
```bash
89+
openclaw update repair
90+
openclaw update repair --channel beta
91+
openclaw update repair --json
92+
```
93+
94+
Options:
95+
96+
- `--channel <stable|beta|dev>`: persist the update channel before repair and
97+
run plugin convergence against that channel.
98+
- `--json`: print machine-readable finalization JSON.
99+
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
100+
- `--yes`: skip confirmation prompts.
101+
- `--no-restart`: accepted for update command parity; repair never restarts the
102+
Gateway.
103+
104+
`openclaw update repair` runs `openclaw doctor --fix`, reloads the repaired
105+
config and install records, syncs tracked plugins for the active update channel,
106+
updates managed npm plugin installs, repairs missing configured plugin payloads,
107+
refreshes the plugin registry, and writes the converged install-record metadata.
108+
It does not install a new core package and does not restart the Gateway.
109+
79110
## `update wizard`
80111

81112
Interactive flow to pick an update channel and confirm whether to restart the Gateway
@@ -218,9 +249,9 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
218249
</Warning>
219250

220251
<Note>
221-
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
252+
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw update repair`.
222253

223-
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
254+
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
224255

225256
When the updated Gateway starts, plugin loading is verify-only: startup does not
226257
run package managers or mutate dependency trees. Package-manager `update.run`

src/cli/update-cli.option-collisions.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,33 @@ describe("update cli option collisions", () => {
8888
).toBe(false);
8989
},
9090
},
91+
{
92+
name: "forwards parent-captured --json/--timeout to `update repair`",
93+
argv: ["update", "repair", "--json", "--timeout", "19"],
94+
assert: () => {
95+
expect(updateFinalizeCommand).toHaveBeenCalledTimes(1);
96+
const opts = firstCallOptions(updateFinalizeCommand);
97+
expect(
98+
(opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.json,
99+
).toBe(true);
100+
expect(
101+
(opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.timeout,
102+
).toBe("19");
103+
expect(
104+
(opts as { json?: boolean; timeout?: string; restart?: boolean } | undefined)?.restart,
105+
).toBe(false);
106+
},
107+
},
108+
{
109+
name: "forwards repair channel and confirmation options",
110+
argv: ["update", "repair", "--channel", "beta", "--yes"],
111+
assert: () => {
112+
expect(updateFinalizeCommand).toHaveBeenCalledTimes(1);
113+
const opts = firstCallOptions(updateFinalizeCommand);
114+
expect((opts as { channel?: string; yes?: boolean } | undefined)?.channel).toBe("beta");
115+
expect((opts as { channel?: string; yes?: boolean } | undefined)?.yes).toBe(true);
116+
},
117+
},
91118
{
92119
name: "keeps hidden `update finalize --no-restart` as a no-op parity flag",
93120
argv: ["update", "finalize", "--no-restart"],

src/cli/update-cli.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,7 +1028,7 @@ describe("update-cli", () => {
10281028
reason: "missing-extension-entry: ./dist/index.js",
10291029
message:
10301030
'Plugin "demo" failed post-core payload smoke check (missing-extension-entry): ./dist/index.js',
1031-
guidance: ["Run openclaw doctor --fix to attempt automatic repair."],
1031+
guidance: ["Run openclaw update repair to retry post-update plugin repair."],
10321032
},
10331033
],
10341034
sync: {
@@ -1727,13 +1727,13 @@ describe("update-cli", () => {
17271727
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
17281728
expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo");
17291729
expect(pluginWarning(jsonOutput)?.guidance).toEqual([
1730-
"Run openclaw doctor --fix to attempt automatic repair.",
1730+
"Run openclaw update repair to retry post-update plugin repair.",
17311731
"Run openclaw plugins inspect demo --runtime --json for details.",
17321732
]);
17331733
expect(pluginWarning(jsonOutput)?.reason).toContain("npm package integrity drift");
17341734
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.status).toBe("error");
17351735
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
1736-
"Run openclaw doctor --fix to attempt automatic repair.",
1736+
"Run openclaw update repair to retry post-update plugin repair.",
17371737
);
17381738
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain(
17391739
"Run openclaw plugins inspect demo --runtime --json for details.",
@@ -1821,7 +1821,7 @@ describe("update-cli", () => {
18211821
.mock.calls.map((call) => String(call[0]))
18221822
.join("\n");
18231823
expect(logs).toContain("Failed to update demo: registry timeout");
1824-
expect(logs).toContain("Run openclaw doctor --fix to attempt automatic repair.");
1824+
expect(logs).toContain("Run openclaw update repair to retry post-update plugin repair.");
18251825
expect(logs).toContain("Run openclaw plugins inspect demo --runtime --json for details.");
18261826
});
18271827

@@ -1846,7 +1846,7 @@ describe("update-cli", () => {
18461846
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("warning");
18471847
expect(pluginWarning(jsonOutput)?.pluginId).toBe("demo");
18481848
expect(pluginWarning(jsonOutput)?.guidance).toEqual([
1849-
"Run openclaw doctor --fix to attempt automatic repair.",
1849+
"Run openclaw update repair to retry post-update plugin repair.",
18501850
"Run openclaw plugins inspect demo --runtime --json for details.",
18511851
]);
18521852
expect(pluginOutcome(jsonOutput)?.pluginId).toBe("demo");
@@ -1890,9 +1890,9 @@ describe("update-cli", () => {
18901890
pluginId: "demo",
18911891
reason: "Failed to update demo: registry timeout",
18921892
message:
1893-
'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw doctor --fix to attempt automatic repair. Run openclaw plugins inspect demo --runtime --json for details.',
1893+
'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw update repair to retry post-update plugin repair. Run openclaw plugins inspect demo --runtime --json for details.',
18941894
guidance: [
1895-
"Run openclaw doctor --fix to attempt automatic repair.",
1895+
"Run openclaw update repair to retry post-update plugin repair.",
18961896
"Run openclaw plugins inspect demo --runtime --json for details.",
18971897
],
18981898
},
@@ -1933,7 +1933,7 @@ describe("update-cli", () => {
19331933
expect(jsonOutput?.status).toBe("ok");
19341934
expect(jsonOutput?.reason).toBeUndefined();
19351935
expect(jsonOutput?.postUpdate?.plugins?.warnings?.[0]?.guidance).toContain(
1936-
"Run openclaw doctor --fix to attempt automatic repair.",
1936+
"Run openclaw update repair to retry post-update plugin repair.",
19371937
);
19381938
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.message).toContain("registry timeout");
19391939
});

src/cli/update-cli.ts

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,44 @@ function inheritedUpdateTimeout(
3838
return inheritOptionFromParent<string>(command, "timeout");
3939
}
4040

41+
function registerUpdateRepairCommand(update: Command, name: string, params?: { hidden?: boolean }) {
42+
const command = params?.hidden ? update.command(name, { hidden: true }) : update.command(name);
43+
command
44+
.description("Repair post-update doctor and plugin convergence")
45+
.option("--json", "Output result as JSON", false)
46+
.option("--channel <stable|beta|dev>", "Persist update channel before repair")
47+
.option("--timeout <seconds>", "Timeout for update repair steps in seconds (default: 1800)")
48+
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
49+
.option("--no-restart", "Accepted for update command parity; repair never restarts")
50+
.addHelpText(
51+
"after",
52+
() =>
53+
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
54+
["openclaw update repair", "Rerun post-update doctor and plugin convergence."],
55+
["openclaw update repair --channel beta", "Repair against the beta update channel."],
56+
["openclaw update repair --json", "JSON output for automation."],
57+
])}\n\n${theme.heading("Notes:")}\n${theme.muted(
58+
"- Repairs post-update plugin state after the core package already changed",
59+
)}\n${theme.muted("- Runs doctor repair and plugin convergence, but never restarts the Gateway")}\n\n${theme.muted(
60+
"Docs:",
61+
)} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`,
62+
)
63+
.action(async (opts, command) => {
64+
try {
65+
await updateFinalizeCommand({
66+
json: Boolean(opts.json) || inheritedUpdateJson(command),
67+
channel: opts.channel as string | undefined,
68+
timeout: inheritedUpdateTimeout(opts, command),
69+
yes: Boolean(opts.yes),
70+
restart: false,
71+
});
72+
} catch (err) {
73+
defaultRuntime.error(String(err));
74+
defaultRuntime.exit(1);
75+
}
76+
});
77+
}
78+
4179
/** Attach the update command group to the root CLI. */
4280
export function registerUpdateCli(program: Command) {
4381
program.enablePositionalOptions();
@@ -65,6 +103,7 @@ export function registerUpdateCli(program: Command) {
65103
["openclaw update --no-restart", "Update without restarting the service"],
66104
["openclaw update --json", "Output result as JSON"],
67105
["openclaw update --yes", "Non-interactive (accept downgrade prompts)"],
106+
["openclaw update repair", "Repair stranded post-update plugin state"],
68107
["openclaw update wizard", "Interactive update wizard"],
69108
["openclaw --update", "Shorthand for openclaw update"],
70109
] as const;
@@ -115,31 +154,8 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
115154
}
116155
});
117156

118-
update
119-
.command("finalize", { hidden: true })
120-
.description("Run OpenClaw update finalization after an external core runtime change")
121-
.option("--json", "Output result as JSON", false)
122-
.option("--channel <stable|beta|dev>", "Persist update channel for finalization")
123-
.option(
124-
"--timeout <seconds>",
125-
"Timeout for update finalization steps in seconds (default: 1800)",
126-
)
127-
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
128-
.option("--no-restart", "Accepted for update command parity; finalization never restarts")
129-
.action(async (opts, command) => {
130-
try {
131-
await updateFinalizeCommand({
132-
json: Boolean(opts.json) || inheritedUpdateJson(command),
133-
channel: opts.channel as string | undefined,
134-
timeout: inheritedUpdateTimeout(opts, command),
135-
yes: Boolean(opts.yes),
136-
restart: false,
137-
});
138-
} catch (err) {
139-
defaultRuntime.error(String(err));
140-
defaultRuntime.exit(1);
141-
}
142-
});
157+
registerUpdateRepairCommand(update, "repair");
158+
registerUpdateRepairCommand(update, "finalize", { hidden: true });
143159

144160
update
145161
.command("wizard")

src/cli/update-cli/post-core-plugin-convergence.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ describe("runPostCorePluginConvergence", () => {
297297
'Failed to install missing configured plugin "discord" from @openclaw/discord: ENETUNREACH.',
298298
message:
299299
'Failed to install missing configured plugin "discord" from @openclaw/discord: ENETUNREACH.',
300-
guidance: ["Run `openclaw doctor --fix` to retry plugin repair."],
300+
guidance: ["Run `openclaw update repair` to retry plugin repair."],
301301
},
302302
]);
303303
});
@@ -324,7 +324,7 @@ describe("runPostCorePluginConvergence", () => {
324324
'Failed to install missing configured plugin "matrix" from clawhub:@openclaw/matrix@beta: ClawHub ClawPack download for @openclaw/matrix@2026.6.1-beta.1 body stalled after 30000ms.',
325325
message:
326326
'Failed to install missing configured plugin "matrix" from clawhub:@openclaw/matrix@beta: ClawHub ClawPack download for @openclaw/matrix@2026.6.1-beta.1 body stalled after 30000ms.',
327-
guidance: ["Run `openclaw doctor --fix` to retry plugin repair."],
327+
guidance: ["Run `openclaw update repair` to retry plugin repair."],
328328
},
329329
]);
330330
expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledWith({
@@ -396,7 +396,7 @@ describe("runPostCorePluginConvergence", () => {
396396
message:
397397
'Plugin "brave" failed post-core payload smoke check (missing-main-entry): Plugin main entry "dist/index.js" not found at /p/brave/dist/index.js',
398398
guidance: [
399-
"Run `openclaw doctor --fix` to retry plugin repair.",
399+
"Run `openclaw update repair` to retry plugin repair.",
400400
"Run `openclaw plugins inspect brave --runtime --json` for details.",
401401
],
402402
},
@@ -433,7 +433,7 @@ describe("runPostCorePluginConvergence", () => {
433433
message:
434434
'Plugin "brave" failed post-core payload smoke check (missing-install-path): Install path is missing from the plugin install record.',
435435
guidance: [
436-
"Run `openclaw doctor --fix` to retry plugin repair.",
436+
"Run `openclaw update repair` to retry plugin repair.",
437437
"Run `openclaw plugins inspect brave --runtime --json` for details.",
438438
],
439439
},
@@ -473,12 +473,12 @@ describe("convergenceWarningsToOutcomes", () => {
473473
pluginId: "brave",
474474
reason: "missing-main-entry: …",
475475
message: 'Plugin "brave" failed payload smoke check.',
476-
guidance: ["Run `openclaw doctor --fix`."],
476+
guidance: ["Run `openclaw update repair`."],
477477
},
478478
{
479479
reason: "Failed install",
480480
message: "Failed install for some plugin.",
481-
guidance: ["Run `openclaw doctor --fix`."],
481+
guidance: ["Run `openclaw update repair`."],
482482
},
483483
],
484484
errored: true,

src/cli/update-cli/post-core-plugin-convergence.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export type PostCoreConvergenceResult = {
4343
installRecords: Record<string, PluginInstallRecord>;
4444
};
4545

46-
const REPAIR_GUIDANCE = "Run `openclaw doctor --fix` to retry plugin repair.";
46+
const REPAIR_GUIDANCE = "Run `openclaw update repair` to retry plugin repair.";
4747
const inspectGuidance = (pluginId: string) =>
4848
`Run \`openclaw plugins inspect ${pluginId} --runtime --json\` for details.`;
4949

src/cli/update-cli/update-command.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,7 @@ describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
675675
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.",
676676
guidance: [
677677
"Run `openclaw doctor` to inspect the config validation errors.",
678-
"Once the config parses, rerun `openclaw update`.",
678+
"Once the config parses, rerun `openclaw update repair`.",
679679
],
680680
},
681681
]);
@@ -694,7 +694,7 @@ describe("buildInvalidConfigPostCoreUpdateResult", () => {
694694
const built = buildInvalidConfigPostCoreUpdateResult();
695695
expect(built.guidance).toStrictEqual([
696696
"Run `openclaw doctor` to inspect the config validation errors.",
697-
"Once the config parses, rerun `openclaw update`.",
697+
"Once the config parses, rerun `openclaw update repair`.",
698698
]);
699699
expect(built.result.warnings).toStrictEqual([
700700
{

src/cli/update-cli/update-command.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ const POST_INSTALL_DOCTOR_SERVICE_ENV_KEYS = [
170170
...SERVICE_REFRESH_PATH_ENV_KEYS,
171171
"OPENCLAW_PROFILE",
172172
] as const;
173-
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE = "Run openclaw doctor --fix to attempt automatic repair.";
173+
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE =
174+
"Run openclaw update repair to retry post-update plugin repair.";
174175
const JSON_MODE_SERVICE_STDOUT = new Writable({
175176
write(_chunk, _encoding, callback) {
176177
callback();
@@ -596,7 +597,7 @@ export function buildInvalidConfigPostCoreUpdateResult(): {
596597
} {
597598
const guidance = [
598599
"Run `openclaw doctor` to inspect the config validation errors.",
599-
"Once the config parses, rerun `openclaw update`.",
600+
"Once the config parses, rerun `openclaw update repair`.",
600601
];
601602
const message =
602603
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";

0 commit comments

Comments
 (0)