fix(plugins): preserve install index state across failures (#119228)

* fix(plugins): preserve install index policy config

* fix(plugins): restore complete install index state

* fix(plugins): fence install index rollback

* fix(plugins): fence generic install index rollback

* fix(plugins): keep lifecycle lease context private

* test(cli): align plugin index rollback mocks

* test(plugins): enforce index rollback receipts

* fix(plugins): serialize install rollback with config commit

* test(plugins): keep legacy index writer mock private
This commit is contained in:
Vincent Koc
2026-08-05 01:04:17 +08:00
committed by GitHub
parent bfba83956f
commit ef11eae39b
17 changed files with 1314 additions and 242 deletions
+98 -48
View File
@@ -18,15 +18,21 @@ import { isPathInside } from "../infra/path-guards.js";
import {
loadInstalledPluginIndexInstallRecords,
PLUGIN_INSTALLS_CONFIG_PATH,
type InstalledPluginIndexRecordStoreOptions,
withoutPluginInstallRecords,
writePersistedInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecordsWithLease,
} from "./installed-plugin-index-records.js";
import {
restorePersistedInstalledPluginIndexIfCurrent,
type InstalledPluginIndexWriteReceipt,
} from "./installed-plugin-index-store.js";
import {
clearRetainedManagedNpmInstallMarker,
markRetainedManagedNpmInstall,
resolveRetainedManagedNpmInstallPackageInfo,
resolveRetainedManagedNpmInstallMarkerPath,
} from "./managed-npm-retention.js";
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
import { planPluginUninstall } from "./uninstall.js";
function mergeUnsetPaths(
@@ -272,33 +278,46 @@ async function restoreClearedRetainedManagedNpmInstallMarkers(
}
async function commitPluginInstallRecordsWithWriter(params: {
previousInstallRecords?: Record<string, PluginInstallRecord>;
nextInstallRecords: Record<string, PluginInstallRecord>;
prepareInstallRecords: (storeOptions: InstalledPluginIndexRecordStoreOptions) => Promise<{
previousInstallRecords: Record<string, PluginInstallRecord>;
nextInstallRecords: Record<string, PluginInstallRecord>;
}>;
nextConfig: OpenClawConfig;
writeOptions?: ConfigWriteOptions;
commit: ConfigCommit;
}): Promise<ConfigReplaceResult | void> {
const previousInstallRecords =
params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
const retainedMarkerPaths: string[] = [];
const clearedMarkerSnapshots: Array<{ markerPath: string; contents: string }> = [];
try {
await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords);
}): Promise<{
committed: ConfigReplaceResult | void;
nextInstallRecords: Record<string, PluginInstallRecord>;
}> {
return await withPluginLifecycleLease({}, async (lease) => {
let tentativeWrite: InstalledPluginIndexWriteReceipt | undefined;
const retainedMarkerPaths: string[] = [];
const clearedMarkerSnapshots: Array<{ markerPath: string; contents: string }> = [];
try {
const storeOptions = { filePath: lease.databasePath };
const prepared = await params.prepareInstallRecords(storeOptions);
tentativeWrite = await writePersistedInstalledPluginIndexInstallRecordsWithLease(
prepared.nextInstallRecords,
{
...storeOptions,
config: params.nextConfig,
lease,
},
);
await markRetainedReplacedManagedNpmInstallRecords({
previousInstallRecords,
nextInstallRecords: params.nextInstallRecords,
// Keep partial progress visible to the outer rollback path.
previousInstallRecords: prepared.previousInstallRecords,
nextInstallRecords: prepared.nextInstallRecords,
// Keep partial progress visible to the rollback path.
createdMarkerPaths: retainedMarkerPaths,
});
clearedMarkerSnapshots.push(
...(await clearActiveRetainedManagedNpmInstallMarkers(params.nextInstallRecords)),
...(await clearActiveRetainedManagedNpmInstallMarkers(prepared.nextInstallRecords)),
);
const installRecordsChanged = !isDeepStrictEqual(
previousInstallRecords,
params.nextInstallRecords,
prepared.previousInstallRecords,
prepared.nextInstallRecords,
);
return await params.commit(params.nextConfig, {
const committed = await params.commit(params.nextConfig, {
...params.writeOptions,
...(installRecordsChanged && params.writeOptions?.afterWrite === undefined
? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } }
@@ -307,23 +326,35 @@ async function commitPluginInstallRecordsWithWriter(params: {
Array.from(PLUGIN_INSTALLS_CONFIG_PATH),
]),
});
return { committed, nextInstallRecords: prepared.nextInstallRecords };
} catch (error) {
try {
// Keep config and install index atomic from the caller's perspective.
await writePersistedInstalledPluginIndexInstallRecords(previousInstallRecords);
} catch (rollbackError) {
throw new Error(
"Failed to commit plugin install records and could not restore the previous plugin index",
{ cause: rollbackError },
);
const tentative = tentativeWrite;
if (tentative) {
try {
const restored = await restorePersistedInstalledPluginIndexIfCurrent(
tentative.previous,
tentative.revision,
{
filePath: lease.databasePath,
lease,
},
);
if (restored) {
// Marker compensation belongs to the same tentative revision. A newer
// index owner may rely on the current marker state.
await restoreClearedRetainedManagedNpmInstallMarkers(clearedMarkerSnapshots);
await removeCreatedRetainedManagedNpmInstallMarkers(retainedMarkerPaths);
}
} catch (rollbackError) {
throw new Error(
"Failed to commit plugin install records and could not roll back tentative plugin state",
{ cause: rollbackError },
);
}
}
throw error;
}
} catch (error) {
await restoreClearedRetainedManagedNpmInstallMarkers(clearedMarkerSnapshots);
await removeCreatedRetainedManagedNpmInstallMarkers(retainedMarkerPaths);
throw error;
}
});
}
/** Persist plugin install records and commit the matching config update to disk. */
@@ -335,7 +366,14 @@ export async function commitPluginInstallRecordsWithConfig(params: {
writeOptions?: ConfigWriteOptions;
}): Promise<void> {
await commitPluginInstallRecordsWithWriter({
...params,
prepareInstallRecords: async (storeOptions) => ({
previousInstallRecords:
params.previousInstallRecords ??
(await loadInstalledPluginIndexInstallRecords(storeOptions)),
nextInstallRecords: params.nextInstallRecords,
}),
nextConfig: params.nextConfig,
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
commit: async (nextConfig, writeOptions) => {
return await replaceConfigFile({
nextConfig,
@@ -350,12 +388,17 @@ export async function commitPluginInstallRecordsWithConfig(params: {
export async function commitPluginInstallRecordsOnly(params: {
previousInstallRecords?: Record<string, PluginInstallRecord>;
nextInstallRecords: Record<string, PluginInstallRecord>;
nextConfig: OpenClawConfig;
verifyConfigFresh?: () => Promise<void>;
}): Promise<void> {
await commitPluginInstallRecordsWithWriter({
previousInstallRecords: params.previousInstallRecords,
nextInstallRecords: params.nextInstallRecords,
nextConfig: {},
prepareInstallRecords: async (storeOptions) => ({
previousInstallRecords:
params.previousInstallRecords ??
(await loadInstalledPluginIndexInstallRecords(storeOptions)),
nextInstallRecords: params.nextInstallRecords,
}),
nextConfig: params.nextConfig,
commit: async () => {
await params.verifyConfigFresh?.();
return undefined;
@@ -401,25 +444,28 @@ export async function commitConfigWriteWithPendingPluginInstalls(params: {
}
const pendingInstallRecords = nextPendingConfig.plugins?.installs ?? {};
const previousInstallRecords = await loadInstalledPluginIndexInstallRecords();
const nextInstallRecords = {
...sourceInstallRecords,
...previousInstallRecords,
...pendingInstallRecords,
};
const strippedConfig = withoutPluginInstallRecords(params.nextConfig);
const committed = await commitPluginInstallRecordsWithWriter({
previousInstallRecords,
nextInstallRecords,
const result = await commitPluginInstallRecordsWithWriter({
prepareInstallRecords: async (storeOptions) => {
const previousInstallRecords = await loadInstalledPluginIndexInstallRecords(storeOptions);
return {
previousInstallRecords,
nextInstallRecords: {
...sourceInstallRecords,
...previousInstallRecords,
...pendingInstallRecords,
},
};
},
nextConfig: strippedConfig,
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
commit: params.commit,
});
return {
config: strippedConfig,
installRecords: nextInstallRecords,
installRecords: result.nextInstallRecords,
movedInstallRecords: true,
persistedHash: committed?.persistedHash ?? null,
persistedHash: result.committed?.persistedHash ?? null,
};
}
@@ -479,8 +525,12 @@ export async function transformConfigWithPendingPluginInstalls<T = void>(
};
};
return await transformConfigFileWithRetry<T>({
...params,
commit,
// The config lock is acquired inside the transform. Own the plugin lifecycle
// lease first so pending-record commits keep the canonical lock order.
return await withPluginLifecycleLease({}, async () => {
return await transformConfigFileWithRetry<T>({
...params,
commit,
});
});
}