fix: add plugin host session cleanup seam (#93733)

* fix: add plugin host session cleanup seam (clawdbot-d02.1.9.1.38)

* test: update session accessor writer ratchet
This commit is contained in:
Josh Lehman
2026-06-18 13:29:39 -07:00
committed by GitHub
parent aa39600793
commit b637414871
6 changed files with 381 additions and 226 deletions
@@ -110,6 +110,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/session-usage.ts",
"src/tui/embedded-backend.ts",
"src/config/sessions/cleanup-service.ts",
"src/plugins/host-hook-cleanup.ts",
]);
export const migratedTranscriptWriterFiles = new Set([
@@ -336,6 +337,7 @@ export async function main() {
"src/agents",
"src/auto-reply",
"src/config/sessions",
"src/plugins",
"src/tui",
]);
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
+259
View File
@@ -0,0 +1,259 @@
/** File-backed implementation for plugin host-owned session-state cleanup. */
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js";
import { updateSessionStore } from "./store.js";
import type { SessionEntry } from "./types.js";
/** Cleanup variants owned by plugin host lifecycle paths. */
export type PluginHostSessionCleanupMode = "plugin-owned-state" | "promoted-slots";
export type PluginHostSessionCleanupStoreParams = {
/** Cleanup mode chosen by the plugin host lifecycle reason. */
mode: PluginHostSessionCleanupMode;
/** Plugin owner to clear. Omit only for session-scoped all-plugin cleanup. */
pluginId?: string;
/** Optional canonical key, alias, or runtime session id filter. */
sessionKey?: string;
/** Promoted SessionEntry slots declared by the plugin registry. */
sessionEntrySlotKeys?: ReadonlySet<string>;
/** Per-store file-backed transaction boundary. */
storePath: string;
/** Cancels the cleanup before persistence when host lifecycle state changes. */
shouldCleanup?: () => boolean;
};
function collectStoredSessionEntrySlotKeys(entry: SessionEntry, pluginId?: string): Set<string> {
const slotKeys = new Set<string>();
const storedSlotKeys = entry.pluginExtensionSlotKeys;
if (!storedSlotKeys) {
return slotKeys;
}
const records =
pluginId === undefined
? Object.values(storedSlotKeys)
: storedSlotKeys[pluginId]
? [storedSlotKeys[pluginId]]
: [];
for (const record of records) {
for (const slotKey of Object.values(record)) {
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok) {
slotKeys.add(normalized.key);
}
}
}
return slotKeys;
}
function collectPromotedSessionEntrySlotKeys(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): Set<string> {
const slotKeys = collectStoredSessionEntrySlotKeys(entry, pluginId);
for (const slotKey of sessionEntrySlotKeys ?? []) {
slotKeys.add(slotKey);
}
return slotKeys;
}
function clearPromotedSessionEntrySlots(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
options: { includeStoredSlotKeys?: boolean; pruneSlotOwnership?: boolean } = {},
): void {
const slotKeys =
options.includeStoredSlotKeys === false && sessionEntrySlotKeys
? new Set(sessionEntrySlotKeys)
: collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of slotKeys) {
delete entryRecord[slotKey];
}
if (!options.pruneSlotOwnership || !entry.pluginExtensionSlotKeys) {
return;
}
// Restart cleanup prunes only ownership for slot keys that disappeared from the new registry.
const pruneRecord = (record: Record<string, string>): void => {
for (const [namespace, slotKey] of Object.entries(record)) {
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok && slotKeys.has(normalized.key)) {
delete record[namespace];
}
}
};
if (pluginId) {
const record = entry.pluginExtensionSlotKeys[pluginId];
if (record) {
pruneRecord(record);
if (Object.keys(record).length === 0) {
delete entry.pluginExtensionSlotKeys[pluginId];
}
}
} else {
for (const record of Object.values(entry.pluginExtensionSlotKeys)) {
pruneRecord(record);
}
for (const [ownerPluginId, record] of Object.entries(entry.pluginExtensionSlotKeys)) {
if (Object.keys(record).length === 0) {
delete entry.pluginExtensionSlotKeys[ownerPluginId];
}
}
}
if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) {
delete entry.pluginExtensionSlotKeys;
}
}
/** Clears plugin-owned extension state from one session entry. */
export function clearPluginOwnedSessionState(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): void {
clearPromotedSessionEntrySlots(entry, pluginId, sessionEntrySlotKeys);
if (!pluginId) {
delete entry.pluginExtensions;
delete entry.pluginExtensionSlotKeys;
delete entry.pluginNextTurnInjections;
return;
}
if (entry.pluginExtensions) {
delete entry.pluginExtensions[pluginId];
if (Object.keys(entry.pluginExtensions).length === 0) {
delete entry.pluginExtensions;
}
}
if (entry.pluginExtensionSlotKeys) {
delete entry.pluginExtensionSlotKeys[pluginId];
if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) {
delete entry.pluginExtensionSlotKeys;
}
}
if (entry.pluginNextTurnInjections) {
delete entry.pluginNextTurnInjections[pluginId];
if (Object.keys(entry.pluginNextTurnInjections).length === 0) {
delete entry.pluginNextTurnInjections;
}
}
}
function hasPromotedSessionEntrySlot(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): boolean {
const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
if (slotKeys.size === 0) {
return false;
}
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of slotKeys) {
if (Object.hasOwn(entryRecord, slotKey)) {
return true;
}
}
return false;
}
function hasPluginOwnedSessionState(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): boolean {
if (hasPromotedSessionEntrySlot(entry, pluginId, sessionEntrySlotKeys)) {
return true;
}
if (!pluginId) {
return Boolean(
entry.pluginExtensions || entry.pluginExtensionSlotKeys || entry.pluginNextTurnInjections,
);
}
return Boolean(
entry.pluginExtensions?.[pluginId] ||
entry.pluginExtensionSlotKeys?.[pluginId] ||
entry.pluginNextTurnInjections?.[pluginId],
);
}
function matchesCleanupSession(
entryKey: string,
entry: SessionEntry,
sessionKey?: string,
): boolean {
const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey);
if (!normalizedSessionKey) {
return true;
}
return (
normalizeLowercaseStringOrEmpty(entryKey) === normalizedSessionKey ||
normalizeLowercaseStringOrEmpty(entry.sessionId) === normalizedSessionKey
);
}
function shouldSkipCleanupStore(params: PluginHostSessionCleanupStoreParams): boolean {
if (!params.pluginId && !params.sessionKey) {
return true;
}
return params.mode === "promoted-slots" && (params.sessionEntrySlotKeys?.size ?? 0) === 0;
}
function hasCleanupTarget(
entry: SessionEntry,
params: PluginHostSessionCleanupStoreParams,
): boolean {
if (params.mode === "promoted-slots") {
return hasPromotedSessionEntrySlot(entry, params.pluginId, params.sessionEntrySlotKeys);
}
return hasPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys);
}
function clearCleanupTarget(
entry: SessionEntry,
params: PluginHostSessionCleanupStoreParams,
): void {
if (params.mode === "promoted-slots") {
clearPromotedSessionEntrySlots(entry, params.pluginId, params.sessionEntrySlotKeys, {
includeStoredSlotKeys: false,
pruneSlotOwnership: true,
});
return;
}
clearPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys);
}
/** Clears plugin host-owned session state in one store transaction. */
export async function cleanupPluginHostSessionStore(
params: PluginHostSessionCleanupStoreParams,
): Promise<number> {
if (shouldSkipCleanupStore(params) || (params.shouldCleanup && !params.shouldCleanup())) {
return 0;
}
return await updateSessionStore(
params.storePath,
(store) => {
if (params.shouldCleanup && !params.shouldCleanup()) {
return 0;
}
let clearedInStore = 0;
const now = Date.now();
for (const [entryKey, entry] of Object.entries(store)) {
if (
!matchesCleanupSession(entryKey, entry, params.sessionKey) ||
!hasCleanupTarget(entry, params)
) {
continue;
}
clearCleanupTarget(entry, params);
entry.updatedAt = now;
clearedInStore += 1;
}
return clearedInStore;
},
{
skipSaveWhenResult: (clearedInStore) => clearedInStore === 0,
takeCacheOwnership: true,
},
);
}
+18
View File
@@ -22,6 +22,11 @@ import {
resolveSessionTranscriptPathInDir,
resolveStorePath,
} from "./paths.js";
import {
cleanupPluginHostSessionStore as cleanupFilePluginHostSessionStore,
clearPluginOwnedSessionState,
type PluginHostSessionCleanupStoreParams,
} from "./plugin-host-cleanup.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import {
@@ -379,6 +384,8 @@ export type DeleteSessionEntryLifecycleParams = {
target: SessionLifecycleStoreTarget;
};
export { clearPluginOwnedSessionState };
/** Returns the entry for a canonical or alias session key, if one exists. */
export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | undefined {
if (scope.clone === false) {
@@ -702,6 +709,17 @@ export async function purgeDeletedAgentSessionEntries(
return await purgeFileDeletedAgentSessionEntries(params);
}
/**
* Clears plugin host-owned state inside one resolved session store.
* This is an internal transaction-sized boundary for the storage backend, not
* a Plugin SDK API.
*/
export async function cleanupPluginHostSessionStore(
params: PluginHostSessionCleanupStoreParams,
): Promise<number> {
return await cleanupFilePluginHostSessionStore(params);
}
/** Reads parsed transcript records from an explicit or derived transcript target. */
export async function loadTranscriptEvents(
scope: SessionTranscriptReadScope,
@@ -101,4 +101,85 @@ describe("plugin host cleanup session stores", () => {
},
});
});
it("clears plugin-owned session state across resolved stores without touching unrelated rows", async () => {
stateDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-host-cleanup-multistore-"),
);
previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = stateDir;
const firstStorePath = path.join(stateDir, "agent-a", "sessions.json");
const secondStorePath = path.join(stateDir, "agent-b", "sessions.json");
const beforeUpdatedAt = 100;
const unrelatedUpdatedAt = Date.now();
const firstEntry: SessionEntry = {
sessionId: "shared-session",
updatedAt: beforeUpdatedAt,
pluginExtensions: {
cleanup: { state: { active: true } },
other: { state: { preserved: true } },
},
pluginNextTurnInjections: {
cleanup: [
{
id: "remove",
pluginId: "cleanup",
text: "remove",
placement: "append_context",
createdAt: beforeUpdatedAt,
},
],
},
};
const secondEntry: SessionEntry = {
sessionId: "shared-session",
updatedAt: beforeUpdatedAt,
pluginExtensions: {
cleanup: { state: { active: true } },
},
};
const unrelatedEntry: SessionEntry = {
sessionId: "unrelated-session",
updatedAt: unrelatedUpdatedAt,
pluginExtensions: {
cleanup: { state: { keep: true } },
},
};
await saveSessionStore(
firstStorePath,
{
"agent:a:main": firstEntry,
"agent:a:unrelated": unrelatedEntry,
},
{ skipMaintenance: true },
);
await saveSessionStore(
secondStorePath,
{
"agent:b:other": secondEntry,
},
{ skipMaintenance: true },
);
const result = await runPluginHostCleanup({
cfg: { session: { store: firstStorePath } },
registry: createEmptyPluginRegistry(),
pluginId: "cleanup",
reason: "disable",
sessionKey: "shared-session",
sessionStorePaths: [firstStorePath, secondStorePath],
});
expect(result).toEqual({ cleanupCount: 2, failures: [] });
const firstStore = loadSessionStore(firstStorePath, { skipCache: true });
const secondStore = loadSessionStore(secondStorePath, { skipCache: true });
expect(firstStore["agent:a:main"]?.pluginExtensions).toEqual({
other: { state: { preserved: true } },
});
expect(firstStore["agent:a:main"]?.pluginNextTurnInjections).toBeUndefined();
expect(firstStore["agent:a:main"]?.updatedAt).toBeGreaterThan(beforeUpdatedAt);
expect(firstStore["agent:a:unrelated"]).toEqual(unrelatedEntry);
expect(secondStore["agent:b:other"]?.pluginExtensions).toBeUndefined();
expect(secondStore["agent:b:other"]?.updatedAt).toBeGreaterThan(beforeUpdatedAt);
});
});
+20 -226
View File
@@ -1,10 +1,11 @@
/** Runs plugin cleanup callbacks and clears host-side plugin session/runtime state. */
import fs from "node:fs";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { getRuntimeConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions/store.js";
import {
cleanupPluginHostSessionStore,
clearPluginOwnedSessionState,
} from "../config/sessions/session-accessor.js";
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withPluginHostCleanupTimeout } from "./host-hook-cleanup-timeout.js";
import {
@@ -17,6 +18,8 @@ import type { PluginRegistry } from "./registry-types.js";
import { getActivePluginRegistry } from "./runtime.js";
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
export { clearPluginOwnedSessionState };
/** Failure captured while running plugin cleanup hooks. */
/** Failure captured while running one plugin cleanup callback. */
export type PluginHostCleanupFailure = {
@@ -37,176 +40,6 @@ function shouldCleanPlugin(pluginId: string, filterPluginId?: string): boolean {
return !filterPluginId || pluginId === filterPluginId;
}
function collectStoredSessionEntrySlotKeys(entry: SessionEntry, pluginId?: string): Set<string> {
const slotKeys = new Set<string>();
const storedSlotKeys = entry.pluginExtensionSlotKeys;
if (!storedSlotKeys) {
return slotKeys;
}
const records =
pluginId === undefined
? Object.values(storedSlotKeys)
: storedSlotKeys[pluginId]
? [storedSlotKeys[pluginId]]
: [];
for (const record of records) {
for (const slotKey of Object.values(record)) {
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok) {
slotKeys.add(normalized.key);
}
}
}
return slotKeys;
}
function collectPromotedSessionEntrySlotKeys(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): Set<string> {
const slotKeys = collectStoredSessionEntrySlotKeys(entry, pluginId);
for (const slotKey of sessionEntrySlotKeys ?? []) {
slotKeys.add(slotKey);
}
return slotKeys;
}
function clearPromotedSessionEntrySlots(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
options: { includeStoredSlotKeys?: boolean; pruneSlotOwnership?: boolean } = {},
): void {
const slotKeys =
options.includeStoredSlotKeys === false && sessionEntrySlotKeys
? new Set(sessionEntrySlotKeys)
: collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of slotKeys) {
delete entryRecord[slotKey];
}
if (!options.pruneSlotOwnership || !entry.pluginExtensionSlotKeys) {
return;
}
// Restart cleanup prunes only ownership for slot keys that disappeared from the new registry.
const pruneRecord = (record: Record<string, string>): void => {
for (const [namespace, slotKey] of Object.entries(record)) {
const normalized = normalizeSessionEntrySlotKey(slotKey);
if (normalized.ok && slotKeys.has(normalized.key)) {
delete record[namespace];
}
}
};
if (pluginId) {
const record = entry.pluginExtensionSlotKeys[pluginId];
if (record) {
pruneRecord(record);
if (Object.keys(record).length === 0) {
delete entry.pluginExtensionSlotKeys[pluginId];
}
}
} else {
for (const record of Object.values(entry.pluginExtensionSlotKeys)) {
pruneRecord(record);
}
for (const [ownerPluginId, record] of Object.entries(entry.pluginExtensionSlotKeys)) {
if (Object.keys(record).length === 0) {
delete entry.pluginExtensionSlotKeys[ownerPluginId];
}
}
}
if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) {
delete entry.pluginExtensionSlotKeys;
}
}
/** Clears plugin-owned extension state from one session entry. */
export function clearPluginOwnedSessionState(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): void {
clearPromotedSessionEntrySlots(entry, pluginId, sessionEntrySlotKeys);
if (!pluginId) {
delete entry.pluginExtensions;
delete entry.pluginExtensionSlotKeys;
delete entry.pluginNextTurnInjections;
return;
}
if (entry.pluginExtensions) {
delete entry.pluginExtensions[pluginId];
if (Object.keys(entry.pluginExtensions).length === 0) {
delete entry.pluginExtensions;
}
}
if (entry.pluginExtensionSlotKeys) {
delete entry.pluginExtensionSlotKeys[pluginId];
if (Object.keys(entry.pluginExtensionSlotKeys).length === 0) {
delete entry.pluginExtensionSlotKeys;
}
}
if (entry.pluginNextTurnInjections) {
delete entry.pluginNextTurnInjections[pluginId];
if (Object.keys(entry.pluginNextTurnInjections).length === 0) {
delete entry.pluginNextTurnInjections;
}
}
}
function hasPromotedSessionEntrySlot(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): boolean {
const slotKeys = collectPromotedSessionEntrySlotKeys(entry, pluginId, sessionEntrySlotKeys);
if (slotKeys.size === 0) {
return false;
}
const entryRecord = entry as Record<string, unknown>;
for (const slotKey of slotKeys) {
if (Object.hasOwn(entryRecord, slotKey)) {
return true;
}
}
return false;
}
function hasPluginOwnedSessionState(
entry: SessionEntry,
pluginId?: string,
sessionEntrySlotKeys?: ReadonlySet<string>,
): boolean {
if (hasPromotedSessionEntrySlot(entry, pluginId, sessionEntrySlotKeys)) {
return true;
}
if (!pluginId) {
return Boolean(
entry.pluginExtensions || entry.pluginExtensionSlotKeys || entry.pluginNextTurnInjections,
);
}
return Boolean(
entry.pluginExtensions?.[pluginId] ||
entry.pluginExtensionSlotKeys?.[pluginId] ||
entry.pluginNextTurnInjections?.[pluginId],
);
}
function matchesCleanupSession(
entryKey: string,
entry: SessionEntry,
sessionKey?: string,
): boolean {
const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey);
if (!normalizedSessionKey) {
return true;
}
return (
normalizeLowercaseStringOrEmpty(entryKey) === normalizedSessionKey ||
normalizeLowercaseStringOrEmpty(entry.sessionId) === normalizedSessionKey
);
}
function resolveExistingSessionStorePaths(cfg: OpenClawConfig): string[] {
return [
...new Set(
@@ -257,32 +90,14 @@ async function clearPluginOwnedSessionStores(params: {
if (params.shouldCleanup && !params.shouldCleanup()) {
break;
}
cleared += await updateSessionStore(
cleared += await cleanupPluginHostSessionStore({
storePath,
(store) => {
if (params.shouldCleanup && !params.shouldCleanup()) {
return 0;
}
let clearedInStore = 0;
const now = Date.now();
for (const [entryKey, entry] of Object.entries(store)) {
if (
!matchesCleanupSession(entryKey, entry, params.sessionKey) ||
!hasPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys)
) {
continue;
}
clearPluginOwnedSessionState(entry, params.pluginId, params.sessionEntrySlotKeys);
entry.updatedAt = now;
clearedInStore += 1;
}
return clearedInStore;
},
{
skipSaveWhenResult: (clearedInStore) => clearedInStore === 0,
takeCacheOwnership: true,
},
);
mode: "plugin-owned-state",
pluginId: params.pluginId,
sessionKey: params.sessionKey,
sessionEntrySlotKeys: params.sessionEntrySlotKeys,
shouldCleanup: params.shouldCleanup,
});
}
return cleared;
}
@@ -305,35 +120,14 @@ async function clearPromotedSessionEntrySlotStores(params: {
if (params.shouldCleanup && !params.shouldCleanup()) {
break;
}
cleared += await updateSessionStore(
cleared += await cleanupPluginHostSessionStore({
storePath,
(store) => {
if (params.shouldCleanup && !params.shouldCleanup()) {
return 0;
}
let clearedInStore = 0;
const now = Date.now();
for (const [entryKey, entry] of Object.entries(store)) {
if (
!matchesCleanupSession(entryKey, entry, params.sessionKey) ||
!hasPromotedSessionEntrySlot(entry, params.pluginId, params.sessionEntrySlotKeys)
) {
continue;
}
clearPromotedSessionEntrySlots(entry, params.pluginId, params.sessionEntrySlotKeys, {
includeStoredSlotKeys: false,
pruneSlotOwnership: true,
});
entry.updatedAt = now;
clearedInStore += 1;
}
return clearedInStore;
},
{
skipSaveWhenResult: (clearedInStore) => clearedInStore === 0,
takeCacheOwnership: true,
},
);
mode: "promoted-slots",
pluginId: params.pluginId,
sessionKey: params.sessionKey,
sessionEntrySlotKeys: params.sessionEntrySlotKeys,
shouldCleanup: params.shouldCleanup,
});
}
return cleared;
}
@@ -83,6 +83,7 @@ describe("session accessor boundary guard", () => {
"src/auto-reply/reply/session-reset-model.ts",
"src/auto-reply/reply/session-updates.ts",
"src/auto-reply/reply/session-usage.ts",
"src/plugins/host-hook-cleanup.ts",
"src/tui/embedded-backend.ts",
"src/config/sessions/cleanup-service.ts",
]),