mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
test(doctor): focus outbound custody on nested maintenance (#154123)
Keep the real outer Doctor scope and inner outbound migration lock while avoiding unrelated state migration sweeps in the custody fixture. Assert specific disposal and acquisition causes alongside native writability and physical exclusion. Preserve completed and refused delivery-queue receipt coverage in the existing preparation suite. Keep every deadline unchanged.
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { collectNestedErrorCandidates } from "@openclaw/normalization-core/error-coercion";
|
||||
import { beginDoctorMaintenance } from "../commands/doctor-maintenance.js";
|
||||
import { EMPTY_LEGACY_SESSION_SURFACES } from "../plugins/legacy-session-surfaces.types.js";
|
||||
import { migrateDoctorDeliveryQueues } from "../commands/doctor-outbound-delivery.js";
|
||||
import { PluginLoadFailureError } from "../plugins/loader-shared.js";
|
||||
import { PluginRuntimeCloseRetainedError } from "../plugins/runtime-close-error.js";
|
||||
import { seedDeliveryQueueEntry } from "./delivery-queue-sqlite.test-support.js";
|
||||
import { tryAcquireExclusiveSqliteCoordinator } from "./sqlite-coordinator.js";
|
||||
import { acquireGatewayLifecycleCoordinator } from "./state-database-coordinator.js";
|
||||
import { autoMigrateLegacyState } from "./state-migrations.doctor.js";
|
||||
|
||||
const modes = ["success", "callback-failure", "retained-release", "retained-acquire"] as const;
|
||||
type Mode = (typeof modes)[number];
|
||||
|
||||
async function runMode(stateDir: string, mode: Mode) {
|
||||
process.stderr.write(`Doctor custody case: ${mode}\n`);
|
||||
process.env.HOME = stateDir;
|
||||
process.env.USERPROFILE = stateDir;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
@@ -22,8 +24,14 @@ async function runMode(stateDir: string, mode: Mode) {
|
||||
const pluginId = `doctor-custody-fixture-${mode}`;
|
||||
const pluginDir = path.join(stateDir, "plugin");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
const nativeState: { native?: DatabaseSync; failure: Error; nativePath: string } = {
|
||||
const nativeState: {
|
||||
native?: DatabaseSync;
|
||||
failure: Error;
|
||||
callbackFailure: Error;
|
||||
nativePath: string;
|
||||
} = {
|
||||
failure: new PluginRuntimeCloseRetainedError(new Error("synthetic native resource retained")),
|
||||
callbackFailure: new Error("settled callback failure"),
|
||||
nativePath: path.join(stateDir, "native.sqlite"),
|
||||
};
|
||||
Object.defineProperty(globalThis, "__doctorCustodyFixture", {
|
||||
@@ -44,7 +52,7 @@ async function runMode(stateDir: string, mode: Mode) {
|
||||
api.registerRuntimeLifecycle({ id: "native-custody", dispose() {
|
||||
if (${JSON.stringify(mode)}.startsWith("retained")) throw state.failure;
|
||||
state.native.close();
|
||||
if (${JSON.stringify(mode)} === "callback-failure") throw new Error("settled callback failure");
|
||||
if (${JSON.stringify(mode)} === "callback-failure") throw state.callbackFailure;
|
||||
} });
|
||||
if (${JSON.stringify(mode)} === "retained-acquire") throw new Error("registration failed after resource acquisition");
|
||||
api.on("message_sending", (event) => ({ content: event.content + "|prepared" }));
|
||||
@@ -88,15 +96,14 @@ async function runMode(stateDir: string, mode: Mode) {
|
||||
if (!maintenance) {
|
||||
throw new Error("Expected Doctor maintenance");
|
||||
}
|
||||
const result = await maintenance.run(() =>
|
||||
autoMigrateLegacyState({
|
||||
cfg,
|
||||
env: process.env,
|
||||
doctorOnlyStateMigrations: true,
|
||||
legacySessionSurfaces: EMPTY_LEGACY_SESSION_SURFACES,
|
||||
}),
|
||||
);
|
||||
await maintenance.release();
|
||||
let failure: unknown;
|
||||
try {
|
||||
await maintenance.run(() => migrateDoctorDeliveryQueues({ cfg, stateDir, env: process.env }));
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
await maintenance.release();
|
||||
}
|
||||
const writable = nativeState.native?.isOpen === true;
|
||||
if (nativeState.native?.isOpen) {
|
||||
nativeState.native.exec("INSERT INTO effects VALUES ('after failed cleanup')");
|
||||
@@ -108,8 +115,20 @@ async function runMode(stateDir: string, mode: Mode) {
|
||||
if (nativeState.native?.isOpen) {
|
||||
nativeState.native.close();
|
||||
}
|
||||
const receipt = result.stepReceipts.find((item) => item.id === "delivery-queues");
|
||||
return { mode, blocked, writable, outcome: receipt?.outcome };
|
||||
const errors = collectNestedErrorCandidates(failure);
|
||||
const acquisitionFailure = errors.find((error) => error instanceof PluginLoadFailureError);
|
||||
return {
|
||||
mode,
|
||||
blocked,
|
||||
writable,
|
||||
registered: nativeState.native !== undefined,
|
||||
failed: failure !== undefined,
|
||||
callbackFailure: errors.includes(nativeState.callbackFailure),
|
||||
retainedFailure: errors.includes(nativeState.failure),
|
||||
acquisitionFailure: acquisitionFailure
|
||||
? { pluginIds: acquisitionFailure.pluginIds, message: acquisitionFailure.message }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const [stateRoot, requestedMode] = process.argv.slice(2);
|
||||
|
||||
@@ -12,7 +12,11 @@ type Report = {
|
||||
mode: string;
|
||||
blocked: boolean;
|
||||
writable: boolean;
|
||||
outcome: string;
|
||||
registered: boolean;
|
||||
failed: boolean;
|
||||
callbackFailure: boolean;
|
||||
retainedFailure: boolean;
|
||||
acquisitionFailure: { pluginIds: string[]; message: string } | null;
|
||||
};
|
||||
|
||||
let reports: Report[] = [];
|
||||
@@ -28,7 +32,7 @@ beforeAll(async () => {
|
||||
90_000,
|
||||
{ requireProcessTreeExit: true },
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.error, result.stderr).toBeUndefined();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
reports = JSON.parse(result.stdout.trim().split("\n").at(-1)!) as Report[];
|
||||
}, 100_000);
|
||||
@@ -43,7 +47,17 @@ it.for(["success", "callback-failure", "retained-release", "retained-acquire"])(
|
||||
mode,
|
||||
blocked: mode.startsWith("retained"),
|
||||
writable: mode.startsWith("retained"),
|
||||
outcome: mode === "success" ? "completed" : "refused",
|
||||
registered: true,
|
||||
failed: mode !== "success",
|
||||
callbackFailure: mode === "callback-failure",
|
||||
retainedFailure: mode.startsWith("retained"),
|
||||
acquisitionFailure:
|
||||
mode === "retained-acquire"
|
||||
? {
|
||||
pluginIds: ["doctor-custody-fixture-retained-acquire"],
|
||||
message: expect.stringContaining("registration failed after resource acquisition"),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,6 +24,39 @@ let pluginFile: string;
|
||||
let eventsFile: string;
|
||||
let cfg: OpenClawConfig;
|
||||
|
||||
async function writePluginFixture({ failDisposal = false }: { failDisposal?: boolean } = {}) {
|
||||
await fs.writeFile(
|
||||
pluginFile,
|
||||
`module.exports = {
|
||||
id: "doctor-outbound-fixture",
|
||||
register(api) {
|
||||
const record = (value) => require("node:fs").appendFileSync(${JSON.stringify(eventsFile)}, value + "\\n");
|
||||
api.on("reply_payload_sending", (event) => {
|
||||
record("reply");
|
||||
return { payload: { ...event.payload, text: event.payload.text + "|reply" } };
|
||||
});
|
||||
api.on("message_sending", (event) => {
|
||||
if (api.runtime.config.current().plugins.entries["doctor-outbound-fixture"].enabled !== true) {
|
||||
throw new Error("migration modifier lost its configured runtime");
|
||||
}
|
||||
record("message");
|
||||
return { content: event.content + "|message" };
|
||||
});
|
||||
api.lifecycle.onDispose(async () => {
|
||||
await require("node:fs/promises").appendFile(${JSON.stringify(eventsFile)}, "dispose\\n");
|
||||
});
|
||||
${failDisposal ? 'api.registerRuntimeLifecycle({ id: "receipt-failure", dispose() { throw new Error("synthetic outbound disposal failure"); } });' : ""}
|
||||
api.registerChannel({ plugin: {
|
||||
id: "matrix", meta: { id: "matrix", label: "Fixture", selectionLabel: "Fixture", docsPath: "/fixture", blurb: "Synthetic" },
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: { listAccountIds: () => ["default"], resolveAccount: () => ({}) },
|
||||
outbound: { deliveryMode: "direct", sendText: async () => { throw new Error("migration must not send"); } },
|
||||
} });
|
||||
},
|
||||
};`,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const home = temporary.make("openclaw-doctor-outbound-");
|
||||
stateDir = path.join(home, ".openclaw");
|
||||
@@ -45,35 +78,7 @@ beforeEach(async () => {
|
||||
configSchema: { type: "object" },
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
pluginFile,
|
||||
`module.exports = {
|
||||
id: "doctor-outbound-fixture",
|
||||
register(api) {
|
||||
const record = (value) => require("node:fs").appendFileSync(${JSON.stringify(eventsFile)}, value + "\\n");
|
||||
api.on("reply_payload_sending", (event) => {
|
||||
record("reply");
|
||||
return { payload: { ...event.payload, text: event.payload.text + "|reply" } };
|
||||
});
|
||||
api.on("message_sending", (event) => {
|
||||
if (api.runtime.config.current().plugins.entries["doctor-outbound-fixture"].enabled !== true) {
|
||||
throw new Error("migration modifier lost its configured runtime");
|
||||
}
|
||||
record("message");
|
||||
return { content: event.content + "|message" };
|
||||
});
|
||||
api.lifecycle.onDispose(async () => {
|
||||
await require("node:fs/promises").appendFile(${JSON.stringify(eventsFile)}, "dispose\\n");
|
||||
});
|
||||
api.registerChannel({ plugin: {
|
||||
id: "matrix", meta: { id: "matrix", label: "Fixture", selectionLabel: "Fixture", docsPath: "/fixture", blurb: "Synthetic" },
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: { listAccountIds: () => ["default"], resolveAccount: () => ({}) },
|
||||
outbound: { deliveryMode: "direct", sendText: async () => { throw new Error("migration must not send"); } },
|
||||
} });
|
||||
},
|
||||
};`,
|
||||
);
|
||||
await writePluginFixture();
|
||||
cfg = {
|
||||
plugins: {
|
||||
allow: ["doctor-outbound-fixture"],
|
||||
@@ -149,6 +154,11 @@ describe("Doctor outbound preparation", () => {
|
||||
expect(getGlobalHookRunner()).toBeNull();
|
||||
const result = await repair();
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.stepReceipts.find((receipt) => receipt.id === "delivery-queues")).toMatchObject(
|
||||
{
|
||||
outcome: "completed",
|
||||
},
|
||||
);
|
||||
expect(loadDeliveryQueueEntry("outbound-prepared-v1", "from-file", stateDir)).toMatchObject({
|
||||
preparedBatch: { entries: [{ payload: { text: "original|reply|message" } }] },
|
||||
});
|
||||
@@ -160,6 +170,19 @@ describe("Doctor outbound preparation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("records a refused delivery migration when its resource disposer fails", async () => {
|
||||
await writePluginFixture({ failDisposal: true });
|
||||
seedDeliveryQueueEntry({ queueName: "outbound", entry: legacy("disposal-failure"), stateDir });
|
||||
|
||||
const result = await repair();
|
||||
|
||||
expect(result.stepReceipts.find((receipt) => receipt.id === "delivery-queues")).toMatchObject({
|
||||
outcome: "refused",
|
||||
refusal: { code: "step-threw" },
|
||||
});
|
||||
expect(await fs.readFile(eventsFile, "utf8")).toBe("reply\nmessage\ndispose\n");
|
||||
});
|
||||
|
||||
it("preserves an unclaimed SQLite legacy row when its plugin cannot load", async () => {
|
||||
const entry = legacy("sqlite-only");
|
||||
seedDeliveryQueueEntry({ queueName: "outbound", entry, stateDir });
|
||||
|
||||
Reference in New Issue
Block a user