mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
Couple test-worker arguments to the executable selected by the shared subprocess helper and migrate its runtime-dependent callers. Repair command parsing, runtime pinning, worker observation, storage settlement, and cleanup-clock fixtures while preserving their contracts. Fourteen scoped suites pass on Node 24 and Bun; the Bun readSync gap remains an unchanged stop. Refreshed proof covers 638 passed/1 existing skip on Node and 636 passed/3 existing skips on Bun. P2 review and exact-head ClawSweeper found no actionable defects. Production LOC delta is zero. The first CI head exposed an inherited UI-validator fixture omission. After incorporating its existing main fix, exact-head CI passed, including the published-driver update job. No same-head retry or admin exception was used.
602 lines
23 KiB
TypeScript
602 lines
23 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { createDeferredCore } from "../shared/deferred.js";
|
|
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
|
import type { EmbeddingProviderAdapter } from "./embedding-provider-types.js";
|
|
import { createPluginRecord } from "./loader-records.js";
|
|
import { activatePluginRegistry } from "./loader-shared.js";
|
|
import { getMemoryEmbeddingProvider } from "./memory-embedding-provider-runtime.js";
|
|
import { prepareMemoryRuntimeReload } from "./memory-runtime.js";
|
|
import { getPluginInstance } from "./plugin-instance-scope.js";
|
|
import type { MemoryPluginRuntime } from "./registry-contribution-types.js";
|
|
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
|
import { createTestPluginRegistry } from "./registry-runtime.test-helpers.js";
|
|
import { disposePluginRegistryInstances } from "./runtime.js";
|
|
import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js";
|
|
|
|
const cleanupClock = vi.hoisted(() => ({ setTimeout: globalThis.setTimeout }));
|
|
|
|
beforeEach(() => {
|
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"], shouldClearNativeTimers: true });
|
|
cleanupClock.setTimeout = globalThis.setTimeout;
|
|
globalThis.setTimeout = nativeSetTimeout;
|
|
});
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
vi.mock("./host-hook-cleanup-timeout.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./host-hook-cleanup-timeout.js")>();
|
|
return {
|
|
...actual,
|
|
withPluginHostCleanupTimeout: <T>(hookId: string, cleanup: () => T | Promise<T>) => {
|
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
// Only the cleanup deadline is virtual; asynchronous workers and watchers keep real timers.
|
|
globalThis.setTimeout = cleanupClock.setTimeout;
|
|
try {
|
|
return actual.withPluginHostCleanupTimeout(hookId, cleanup, 500);
|
|
} finally {
|
|
globalThis.setTimeout = nativeSetTimeout;
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
const { memoryRuntime } = await vi.importActual<{ memoryRuntime: MemoryPluginRuntime }>(
|
|
"../../extensions/memory-core/runtime-api.js",
|
|
);
|
|
|
|
it("keeps persistent managers isolated between memory runtime instances with the same agent", async () => {
|
|
const state = await createOpenClawTestState({
|
|
scenario: "minimal",
|
|
label: "memory-runtime-owners",
|
|
});
|
|
const config: OpenClawConfig = {
|
|
plugins: { enabled: false },
|
|
agents: { defaults: { workspace: state.workspaceDir } },
|
|
memory: {
|
|
search: {
|
|
provider: "none",
|
|
store: { vector: { enabled: false } },
|
|
},
|
|
},
|
|
};
|
|
const first = registerMemoryOwner(config);
|
|
const second = registerMemoryOwner(config);
|
|
try {
|
|
const one = await first.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
const two = await second.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(one.manager, one.error ?? "Expected a memory manager");
|
|
assert(two.manager, two.error ?? "Expected a memory manager");
|
|
await first.runtime.closeAllMemorySearchManagers?.();
|
|
expect(two.manager.status().sources).toBeDefined();
|
|
await expect(two.manager.search("sibling still live")).resolves.toEqual([]);
|
|
} finally {
|
|
await first.runtime.closeAllMemorySearchManagers?.();
|
|
await second.runtime.closeAllMemorySearchManagers?.();
|
|
await disposePluginRegistryInstances(first.registry);
|
|
await disposePluginRegistryInstances(second.registry);
|
|
await state.cleanup();
|
|
}
|
|
});
|
|
|
|
function registerMemoryOwner(
|
|
config: OpenClawConfig,
|
|
runtimeImplementation: MemoryPluginRuntime = memoryRuntime,
|
|
) {
|
|
const owner = createTestPluginRegistry();
|
|
const record = createPluginRecord({
|
|
id: "memory-fixture",
|
|
source: "fixture",
|
|
origin: "config",
|
|
enabled: true,
|
|
configSchema: false,
|
|
});
|
|
record.kind = "memory";
|
|
record.memorySlotSelected = true;
|
|
owner.registry.plugins.push(record);
|
|
const api = owner.createApi(record, { config });
|
|
api.registerMemoryCapability({ runtime: runtimeImplementation });
|
|
const runtime = owner.registry.memoryCapabilities[0]?.capability.runtime;
|
|
assert(runtime);
|
|
return { ...owner, record, runtime, instance: getPluginInstance(record)! };
|
|
}
|
|
|
|
function registerAdapter(
|
|
owner: ReturnType<typeof registerMemoryOwner>,
|
|
config: OpenClawConfig,
|
|
adapter: EmbeddingProviderAdapter,
|
|
) {
|
|
const record = createPluginRecord({
|
|
id: adapter.id,
|
|
source: "fixture",
|
|
origin: "config",
|
|
enabled: true,
|
|
configSchema: false,
|
|
contracts: { embeddingProviders: [adapter.id] },
|
|
});
|
|
owner.registry.plugins.push(record);
|
|
owner.createApi(record, { config }).registerEmbeddingProvider(adapter);
|
|
return { record, registration: owner.registry.embeddingProviders.at(-1)! };
|
|
}
|
|
|
|
it.each([
|
|
"ready",
|
|
"legacy-retained",
|
|
"pending-primary",
|
|
"pending-fallback",
|
|
"healthy-fallback",
|
|
"unavailable-create",
|
|
"failed-close",
|
|
"timeout",
|
|
"late-probe",
|
|
"transient",
|
|
] as const)("drains the actual managed embedding owner before replacement (%s)", async (mode) => {
|
|
const state = await createOpenClawTestState({
|
|
scenario: "minimal",
|
|
label: "memory-provider-reload",
|
|
});
|
|
const fallback = mode === "pending-fallback" || mode === "healthy-fallback";
|
|
const providerId = fallback ? "primary-probe" : "embedding-probe";
|
|
const targetId = fallback ? "fallback-probe" : providerId;
|
|
const config: OpenClawConfig = {
|
|
plugins: { enabled: false },
|
|
agents: { defaults: { workspace: state.workspaceDir } },
|
|
memory: {
|
|
search: {
|
|
provider: providerId,
|
|
model: "synthetic-embedding",
|
|
fallback: fallback ? targetId : "none",
|
|
store: { vector: { enabled: false } },
|
|
},
|
|
},
|
|
};
|
|
const legacyRuntime = { ...memoryRuntime };
|
|
delete legacyRuntime.prepareReload;
|
|
const owner = registerMemoryOwner(
|
|
config,
|
|
mode === "legacy-retained" ? legacyRuntime : memoryRuntime,
|
|
);
|
|
const sibling = registerMemoryOwner(config);
|
|
const successor = registerMemoryOwner(config);
|
|
const entered = createDeferredCore();
|
|
const releaseCreate = createDeferredCore();
|
|
const releaseClose = createDeferredCore();
|
|
const closeEntered = createDeferredCore();
|
|
const probeEntered = createDeferredCore();
|
|
const releaseProbe = createDeferredCore();
|
|
let rejectClose = mode === "failed-close";
|
|
const embedBatch = vi.fn(async () => {
|
|
probeEntered.resolve();
|
|
if (mode === "late-probe") {
|
|
await releaseProbe.promise;
|
|
}
|
|
return [[1, 0, 0]];
|
|
});
|
|
const create = vi.fn(async () => {
|
|
entered.resolve();
|
|
if (mode === "unavailable-create") {
|
|
return { provider: null };
|
|
}
|
|
if (mode.startsWith("pending")) {
|
|
await releaseCreate.promise;
|
|
}
|
|
return {
|
|
provider: {
|
|
id: targetId,
|
|
model: "synthetic-embedding",
|
|
embed: async () => [1, 0, 0],
|
|
embedBatch,
|
|
close,
|
|
},
|
|
};
|
|
});
|
|
const close = vi.fn(async () => {
|
|
closeEntered.resolve();
|
|
expect(getPluginRuntimeGatewayRequestScope()?.pluginId).toBe(targetId);
|
|
if (rejectClose) {
|
|
throw new Error("synthetic provider cleanup refused");
|
|
}
|
|
if (mode === "timeout") {
|
|
await releaseClose.promise;
|
|
}
|
|
});
|
|
const adapter = { id: targetId, transport: "remote" as const, create };
|
|
if (fallback) {
|
|
registerAdapter(owner, config, {
|
|
id: providerId,
|
|
create: async () => {
|
|
throw new Error("synthetic primary unavailable");
|
|
},
|
|
});
|
|
}
|
|
const retiring = registerAdapter(owner, config, adapter);
|
|
const siblingClose = vi.fn(async () => {});
|
|
registerAdapter(sibling, config, {
|
|
id: providerId,
|
|
create: async () => ({
|
|
provider: {
|
|
id: providerId,
|
|
model: "synthetic-embedding",
|
|
embed: async () => [1, 0, 0],
|
|
embedBatch: async () => [[1, 0, 0]],
|
|
close: siblingClose,
|
|
},
|
|
}),
|
|
});
|
|
const successorClose = vi.fn(async () => {});
|
|
const replacement = registerAdapter(successor, config, {
|
|
id: targetId,
|
|
create: async () => ({
|
|
provider:
|
|
mode === "late-probe"
|
|
? null
|
|
: {
|
|
id: targetId,
|
|
model: "synthetic-embedding",
|
|
embed: async () => [1, 0, 0],
|
|
embedBatch: async () => [[1, 0, 0]],
|
|
close: successorClose,
|
|
},
|
|
}),
|
|
});
|
|
const unaffectedConfig = {
|
|
...config,
|
|
memory: { search: { ...config.memory?.search, provider: "unaffected-probe" } },
|
|
};
|
|
const unaffectedClose = vi.fn(async () => {});
|
|
if (mode === "ready") {
|
|
registerAdapter(owner, unaffectedConfig, {
|
|
id: "unaffected-probe",
|
|
create: async () => ({
|
|
provider: {
|
|
id: "unaffected-probe",
|
|
model: "synthetic-embedding",
|
|
embed: async () => [1, 0, 0],
|
|
embedBatch: async () => [[1, 0, 0]],
|
|
close: unaffectedClose,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
const next = createEmptyPluginRegistry();
|
|
next.plugins.push(
|
|
...owner.registry.plugins.filter((record) => record !== retiring.record),
|
|
replacement.record,
|
|
);
|
|
next.memoryCapabilities.push(...owner.registry.memoryCapabilities);
|
|
next.embeddingProviders.push(
|
|
...owner.registry.embeddingProviders.filter((entry) => entry !== retiring.registration),
|
|
replacement.registration,
|
|
);
|
|
let probe: Promise<unknown> | undefined;
|
|
let drain: ReturnType<ReturnType<typeof prepareMemoryRuntimeReload>["drain"]> | undefined;
|
|
try {
|
|
expect(owner.instance.run(() => getMemoryEmbeddingProvider(targetId, config))).toBe(
|
|
retiring.registration.provider,
|
|
);
|
|
let result = await owner.runtime.getMemorySearchManager({
|
|
cfg: config,
|
|
agentId: "main",
|
|
...(mode === "transient" ? { purpose: "cli" } : {}),
|
|
});
|
|
const other = await sibling.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(result.manager, result.error ?? "Expected a memory manager");
|
|
assert(other.manager, other.error ?? "Expected a memory manager");
|
|
await other.manager.probeEmbeddingAvailability();
|
|
probe = result.manager.probeEmbeddingAvailability();
|
|
await entered.promise;
|
|
if (mode === "late-probe") {
|
|
await probeEntered.promise;
|
|
} else if (!mode.startsWith("pending")) {
|
|
await probe;
|
|
}
|
|
if (mode === "healthy-fallback") {
|
|
const primaryOnly = prepareMemoryRuntimeReload(owner.registry, {
|
|
...owner.registry,
|
|
embeddingProviders: owner.registry.embeddingProviders.filter(
|
|
(entry) => entry !== owner.registry.embeddingProviders[0],
|
|
),
|
|
});
|
|
await primaryOnly.drain();
|
|
await expect(result.manager.probeEmbeddingAvailability()).rejects.toThrow("closed");
|
|
expect(close).toHaveBeenCalledOnce();
|
|
primaryOnly.rollback();
|
|
const previousManager = result.manager;
|
|
result = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(result.manager, result.error ?? "Expected a fresh fallback manager");
|
|
expect(result.manager).not.toBe(previousManager);
|
|
await expect(result.manager.probeEmbeddingAvailability()).resolves.toMatchObject({
|
|
ok: true,
|
|
});
|
|
}
|
|
if (mode === "legacy-retained") {
|
|
const unchanged = prepareMemoryRuntimeReload(owner.registry, owner.registry);
|
|
await unchanged.drain();
|
|
unchanged.commit();
|
|
expect(close).not.toHaveBeenCalled();
|
|
}
|
|
if (mode === "ready") {
|
|
const unaffected = await owner.runtime.getMemorySearchManager({
|
|
cfg: unaffectedConfig,
|
|
agentId: "unaffected",
|
|
});
|
|
assert(unaffected.manager, unaffected.error ?? "Expected an unaffected manager");
|
|
await unaffected.manager.probeEmbeddingAvailability();
|
|
const prepared = prepareMemoryRuntimeReload(owner.registry, next);
|
|
const probesBeforeAcquisition = embedBatch.mock.calls.length;
|
|
try {
|
|
const retained = await owner.runtime.getMemorySearchManager({
|
|
cfg: unaffectedConfig,
|
|
agentId: "unaffected",
|
|
});
|
|
expect(retained.manager).toBe(unaffected.manager);
|
|
await expect(retained.manager?.probeEmbeddingAvailability()).resolves.toMatchObject({
|
|
ok: true,
|
|
});
|
|
const late = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
if (late.manager) {
|
|
await late.manager.probeEmbeddingAvailability();
|
|
}
|
|
expect({ hasManager: late.manager !== null, error: late.error }).toEqual({
|
|
hasManager: false,
|
|
error: expect.stringContaining("reloading"),
|
|
});
|
|
expect(embedBatch).toHaveBeenCalledTimes(probesBeforeAcquisition);
|
|
expect(close).not.toHaveBeenCalled();
|
|
expect(unaffectedClose).not.toHaveBeenCalled();
|
|
} finally {
|
|
prepared.rollback();
|
|
}
|
|
const resumed = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
expect(resumed.manager).toBe(result.manager);
|
|
await expect(resumed.manager?.probeEmbeddingAvailability()).resolves.toMatchObject({
|
|
ok: true,
|
|
});
|
|
}
|
|
const providerClosesBeforeReload = close.mock.calls.length;
|
|
const reload = prepareMemoryRuntimeReload(owner.registry, next);
|
|
let drained = false;
|
|
drain = reload.drain().then((drainResult) => {
|
|
drained = true;
|
|
return drainResult;
|
|
});
|
|
if (mode.startsWith("pending")) {
|
|
await Promise.resolve();
|
|
expect(drained).toBe(false);
|
|
expect(close).not.toHaveBeenCalled();
|
|
}
|
|
if (mode !== "legacy-retained") {
|
|
// Only prepareReload declares an admission fence for concurrent acquisition.
|
|
const creationsBeforeLateAcquisition = create.mock.calls.length;
|
|
const late = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "late" });
|
|
assert(late.manager, late.error ?? "Expected a memory manager");
|
|
await expect(late.manager.probeEmbeddingAvailability()).rejects.toThrow("reloading");
|
|
expect(create).toHaveBeenCalledTimes(creationsBeforeLateAcquisition);
|
|
}
|
|
releaseCreate.resolve();
|
|
if (mode !== "late-probe") {
|
|
await probe;
|
|
}
|
|
if (mode === "failed-close") {
|
|
await expect(drain).resolves.toMatchObject({
|
|
errors: [expect.objectContaining({ message: "synthetic provider cleanup refused" })],
|
|
});
|
|
expect(siblingClose).not.toHaveBeenCalled();
|
|
activatePluginRegistry(next, null, "gateway-bindable", undefined, owner.registry);
|
|
reload.commit();
|
|
} else if (mode === "timeout") {
|
|
const expired = expect(drain).rejects.toThrow("plugin host cleanup timed out");
|
|
await closeEntered.promise;
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
await expired;
|
|
activatePluginRegistry(next, null, "gateway-bindable", undefined, owner.registry);
|
|
reload.commit();
|
|
const fresh = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(fresh.manager, fresh.error ?? "Expected a replacement manager");
|
|
expect(fresh.manager).not.toBe(result.manager);
|
|
await expect(fresh.manager.probeEmbeddingAvailability()).resolves.toMatchObject({ ok: true });
|
|
let joined = false;
|
|
const finalClose = reload.close().then(() => {
|
|
joined = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(joined).toBe(false);
|
|
releaseClose.resolve();
|
|
await finalClose;
|
|
expect(close).toHaveBeenCalledOnce();
|
|
} else if (mode === "late-probe") {
|
|
const expired = expect(drain).rejects.toThrow("plugin host cleanup timed out");
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
await expired;
|
|
activatePluginRegistry(next, null, "gateway-bindable", undefined, owner.registry);
|
|
reload.commit();
|
|
const fresh = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(fresh.manager, fresh.error ?? "Expected a replacement manager");
|
|
expect(fresh.manager).not.toBe(result.manager);
|
|
await expect(fresh.manager.probeEmbeddingAvailability()).resolves.toMatchObject({
|
|
ok: false,
|
|
});
|
|
releaseProbe.resolve();
|
|
await expect(probe).resolves.toMatchObject({ ok: true });
|
|
await expect(reload.close()).resolves.toMatchObject({
|
|
errors: [
|
|
expect.objectContaining({
|
|
message: `Plugin ${targetId} is retiring`,
|
|
}),
|
|
],
|
|
});
|
|
await expect(fresh.manager.probeEmbeddingAvailability()).resolves.toMatchObject({
|
|
ok: false,
|
|
cached: true,
|
|
});
|
|
} else {
|
|
await drain;
|
|
expect(close).toHaveBeenCalledTimes(
|
|
providerClosesBeforeReload + (mode === "unavailable-create" ? 0 : 1),
|
|
);
|
|
activatePluginRegistry(next, null, "gateway-bindable", undefined, owner.registry);
|
|
reload.commit();
|
|
}
|
|
await expect(result.manager.probeEmbeddingAvailability()).rejects.toThrow("closed");
|
|
await expect(other.manager.probeEmbeddingAvailability()).resolves.toMatchObject({ ok: true });
|
|
expect(siblingClose).not.toHaveBeenCalled();
|
|
expect(unaffectedClose).not.toHaveBeenCalled();
|
|
const fresh = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(fresh.manager, fresh.error ?? "Expected a memory manager");
|
|
await expect(fresh.manager.probeEmbeddingAvailability()).resolves.toMatchObject({
|
|
ok: mode !== "late-probe",
|
|
});
|
|
const attemptedCloses = close.mock.calls.length;
|
|
const finalClose = owner.runtime.closeAllMemorySearchManagers?.();
|
|
if (mode === "failed-close" || mode === "late-probe") {
|
|
await expect(finalClose).rejects.toThrow(`Plugin ${targetId} was reloaded or disabled`);
|
|
expect(close).toHaveBeenCalledTimes(attemptedCloses);
|
|
} else {
|
|
await finalClose;
|
|
}
|
|
expect(successorClose).toHaveBeenCalledTimes(mode === "late-probe" ? 0 : 1);
|
|
} finally {
|
|
rejectClose = false;
|
|
releaseCreate.resolve();
|
|
releaseClose.resolve();
|
|
releaseProbe.resolve();
|
|
await Promise.allSettled([probe, drain]);
|
|
// A failed old provider stays behind its revoked admission; join every fixture owner before assertions.
|
|
const memoryCleanup = await Promise.allSettled([
|
|
owner.runtime.closeAllMemorySearchManagers?.(),
|
|
sibling.runtime.closeAllMemorySearchManagers?.(),
|
|
successor.runtime.closeAllMemorySearchManagers?.(),
|
|
]);
|
|
await disposePluginRegistryInstances(next);
|
|
await disposePluginRegistryInstances(owner.registry);
|
|
await disposePluginRegistryInstances(sibling.registry);
|
|
await disposePluginRegistryInstances(successor.registry);
|
|
await state.cleanup();
|
|
if (mode === "failed-close" || mode === "late-probe") {
|
|
expect(memoryCleanup[0]).toMatchObject({
|
|
status: "rejected",
|
|
reason: expect.objectContaining({
|
|
message: expect.stringContaining(`Plugin ${targetId} was reloaded or disabled`),
|
|
}),
|
|
});
|
|
}
|
|
for (const result of memoryCleanup.slice(
|
|
mode === "failed-close" || mode === "late-probe" ? 1 : 0,
|
|
)) {
|
|
expect(result.status, result.status === "rejected" ? String(result.reason) : undefined).toBe(
|
|
"fulfilled",
|
|
);
|
|
}
|
|
}
|
|
expect(siblingClose).toHaveBeenCalledOnce();
|
|
expect(unaffectedClose).toHaveBeenCalledTimes(mode === "ready" ? 1 : 0);
|
|
expect(successorClose).toHaveBeenCalledTimes(mode === "late-probe" ? 0 : 1);
|
|
});
|
|
|
|
it.each(["legacy-success", "legacy", "modern", "revoked"] as const)(
|
|
"reports admitted memory cleanup failures and preserves admission guards (%s)",
|
|
async (mode) => {
|
|
const owner = registerMemoryOwner({});
|
|
const failure = new Error("memory callback cleanup failed");
|
|
const close = vi.fn(async () => {
|
|
expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(owner.registry);
|
|
if (mode !== "legacy-success") {
|
|
throw failure;
|
|
}
|
|
});
|
|
const runtime: MemoryPluginRuntime = {
|
|
getMemorySearchManager: async () => ({ manager: null }),
|
|
resolveMemoryBackendConfig: () => ({ backend: "builtin" }),
|
|
...(mode === "modern"
|
|
? { prepareReload: () => ({ drain: close, resume() {} }) }
|
|
: { closeAllMemorySearchManagers: close }),
|
|
};
|
|
owner.registry.memoryCapabilities[0]!.capability = owner.instance.wrap({ runtime });
|
|
const prepared = prepareMemoryRuntimeReload(owner.registry, createEmptyPluginRegistry());
|
|
owner.instance.quiesce();
|
|
try {
|
|
if (mode === "revoked") {
|
|
await owner.instance.dispose();
|
|
await expect(prepared.drain()).rejects.toThrow("retiring");
|
|
expect(close).not.toHaveBeenCalled();
|
|
} else {
|
|
const errors = mode === "legacy-success" ? [] : [failure];
|
|
await expect(prepared.drain()).resolves.toEqual({ errors });
|
|
await expect(prepared.close()).resolves.toEqual({ errors });
|
|
prepared.commit();
|
|
expect(close).toHaveBeenCalledOnce();
|
|
}
|
|
} finally {
|
|
await disposePluginRegistryInstances(owner.registry);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("unwinds prepared memory admission when another runtime rejects preparation", async () => {
|
|
const first = registerMemoryOwner({});
|
|
const next = createEmptyPluginRegistry();
|
|
const resume = vi.fn();
|
|
const drain = vi.fn(async () => {});
|
|
const failure = new Error("second memory runtime preparation failed");
|
|
const firstRuntime = first.instance.wrap({
|
|
...memoryRuntime,
|
|
prepareReload: () => ({ drain, resume }),
|
|
});
|
|
first.registry.memoryCapabilities[0]!.capability = first.instance.wrap({ runtime: firstRuntime });
|
|
first.registry.memoryCapabilities.push({
|
|
pluginId: "another-runtime",
|
|
capability: {
|
|
runtime: {
|
|
...memoryRuntime,
|
|
prepareReload() {
|
|
throw failure;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
expect(() => prepareMemoryRuntimeReload(first.registry, next)).toThrow(failure);
|
|
expect(resume).toHaveBeenCalledOnce();
|
|
expect(drain).not.toHaveBeenCalled();
|
|
} finally {
|
|
await disposePluginRegistryInstances(first.registry);
|
|
}
|
|
});
|
|
|
|
it("fences runtime acquisition before its first manager and resumes only on rollback", async () => {
|
|
const state = await createOpenClawTestState({ scenario: "minimal", label: "memory-lazy-reload" });
|
|
const config: OpenClawConfig = {
|
|
plugins: { enabled: false },
|
|
agents: { defaults: { workspace: state.workspaceDir } },
|
|
memory: {
|
|
search: {
|
|
provider: "none",
|
|
store: { vector: { enabled: false } },
|
|
},
|
|
},
|
|
};
|
|
const owner = registerMemoryOwner(config);
|
|
try {
|
|
const older = prepareMemoryRuntimeReload(owner.registry, createEmptyPluginRegistry());
|
|
const reload = prepareMemoryRuntimeReload(owner.registry, createEmptyPluginRegistry());
|
|
await reload.drain();
|
|
older.rollback();
|
|
expect(
|
|
await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" }),
|
|
).toMatchObject({ manager: null, error: expect.stringContaining("reloading") });
|
|
reload.rollback();
|
|
const acquired = await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" });
|
|
assert(acquired.manager, acquired.error ?? "Expected a memory manager");
|
|
const retiring = prepareMemoryRuntimeReload(owner.registry, createEmptyPluginRegistry());
|
|
await retiring.close();
|
|
retiring.commit();
|
|
expect(
|
|
await owner.runtime.getMemorySearchManager({ cfg: config, agentId: "main" }),
|
|
).toMatchObject({ manager: null, error: expect.stringContaining("reloading") });
|
|
} finally {
|
|
await owner.runtime.closeAllMemorySearchManagers?.();
|
|
await disposePluginRegistryInstances(owner.registry);
|
|
await state.cleanup();
|
|
}
|
|
});
|