mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
fix: handle rebased config mutation races
This commit is contained in:
@@ -8,6 +8,25 @@ const mocks = vi.hoisted(() => ({
|
||||
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => {
|
||||
await mocks.writeConfigFile(nextConfig);
|
||||
}),
|
||||
mutateConfigFile: vi.fn(
|
||||
async (params: {
|
||||
mutate: (draft: OpenClawConfig, context: { snapshot: { path: string } }) => unknown;
|
||||
}) => {
|
||||
const draft = structuredClone(mocks.getRuntimeConfig());
|
||||
const result = await params.mutate(draft, { snapshot: { path: "/tmp/openclaw.json" } });
|
||||
await mocks.writeConfigFile(draft);
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: "test-hash",
|
||||
snapshot: { path: "/tmp/openclaw.json" },
|
||||
nextConfig: draft,
|
||||
result,
|
||||
attempts: 1,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { action: "none" },
|
||||
};
|
||||
},
|
||||
),
|
||||
resolveGatewayAuth: vi.fn(
|
||||
({
|
||||
authConfig,
|
||||
@@ -53,6 +72,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("../config/config.js", () => ({
|
||||
getRuntimeConfig: mocks.getRuntimeConfig,
|
||||
replaceConfigFile: mocks.replaceConfigFile,
|
||||
mutateConfigFile: mocks.mutateConfigFile,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/startup-auth.js", () => ({
|
||||
@@ -146,6 +166,7 @@ describe("ensureBrowserControlAuth", () => {
|
||||
vi.restoreAllMocks();
|
||||
mocks.getRuntimeConfig.mockClear();
|
||||
mocks.writeConfigFile.mockClear();
|
||||
mocks.mutateConfigFile.mockClear();
|
||||
mocks.resolveGatewayAuth.mockClear();
|
||||
mocks.ensureGatewayStartupAuth.mockClear();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,27 @@ import type { BrowserRouteContext, BrowserServerState } from "./server-context.j
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
getRuntimeConfig: vi.fn<() => OpenClawConfig>(),
|
||||
writeConfigFile: vi.fn<(cfg: OpenClawConfig) => Promise<void>>(async (_cfg) => {}),
|
||||
mutateConfigFile: vi.fn(
|
||||
async (params: {
|
||||
mutate: (draft: OpenClawConfig, context: { snapshot: { path: string } }) => unknown;
|
||||
}) => {
|
||||
const draft = structuredClone(configMocks.getRuntimeConfig());
|
||||
const result = await params.mutate(draft, { snapshot: { path: "/tmp/openclaw.json" } });
|
||||
await configMocks.writeConfigFile(draft);
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: "test-hash",
|
||||
snapshot: { path: "/tmp/openclaw.json" },
|
||||
nextConfig: draft,
|
||||
result,
|
||||
attempts: 1,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { action: "none" },
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
const writeConfigFile = configMocks.writeConfigFile;
|
||||
|
||||
@@ -16,10 +36,11 @@ vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeConfig: vi.fn(),
|
||||
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => {
|
||||
await configMocks.writeConfigFile(nextConfig);
|
||||
}),
|
||||
mutateConfigFile: configMocks.mutateConfigFile,
|
||||
getRuntimeConfig: configMocks.getRuntimeConfig,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -423,7 +423,29 @@ vi.mock("../config/config.js", async () => {
|
||||
},
|
||||
};
|
||||
};
|
||||
const writeConfigFile = vi.fn(async () => {});
|
||||
const writeConfigFile = vi.fn(async (_cfg?: ReturnType<typeof loadConfig>) => {});
|
||||
const mutateConfigFile = vi.fn(
|
||||
async (params: {
|
||||
mutate: (
|
||||
draft: ReturnType<typeof loadConfig>,
|
||||
context: { snapshot: { path: string } },
|
||||
) => unknown;
|
||||
}) => {
|
||||
const draft = structuredClone(loadConfig());
|
||||
const result = await params.mutate(draft, { snapshot: { path: "/tmp/openclaw.json" } });
|
||||
await writeConfigFile(draft);
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: "test-hash",
|
||||
snapshot: { path: "/tmp/openclaw.json" },
|
||||
nextConfig: draft,
|
||||
result,
|
||||
attempts: 1,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { action: "none" },
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
createConfigIO: vi.fn(() => ({
|
||||
@@ -434,6 +456,7 @@ vi.mock("../config/config.js", async () => {
|
||||
getRuntimeConfigSnapshot: vi.fn(() => null),
|
||||
loadConfig,
|
||||
writeConfigFile,
|
||||
mutateConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ function installPerKeySequentializer(): void {
|
||||
}
|
||||
|
||||
function mockTelegramConfigWrites() {
|
||||
return vi.spyOn(configMutation, "replaceConfigFile").mockResolvedValue({} as never);
|
||||
return vi.spyOn(configMutation, "mutateConfigFile").mockResolvedValue({} as never);
|
||||
}
|
||||
|
||||
async function withEnvAsync(env: Record<string, string | undefined>, fn: () => Promise<void>) {
|
||||
|
||||
@@ -583,6 +583,23 @@ describe("agents.create", () => {
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an invalid request when a concurrent create wins the config race", async () => {
|
||||
let findCallCount = 0;
|
||||
mocks.findAgentEntryIndex.mockImplementation(() => {
|
||||
findCallCount += 1;
|
||||
return findCallCount >= 2 ? 0 : -1;
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.create", {
|
||||
name: "Race Agent",
|
||||
workspace: "/tmp/ws",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectRespondErrorContaining(respond, "already exists");
|
||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid params (missing name)", async () => {
|
||||
const { respond, promise } = makeCall("agents.create", {
|
||||
workspace: "/tmp/ws",
|
||||
@@ -770,6 +787,22 @@ describe("agents.update", () => {
|
||||
expectNotFoundResponseAndNoWrite(respond);
|
||||
});
|
||||
|
||||
it("returns not found when a concurrent delete wins the update race", async () => {
|
||||
let findCallCount = 0;
|
||||
mocks.findAgentEntryIndex.mockImplementation(() => {
|
||||
findCallCount += 1;
|
||||
return findCallCount >= 2 ? -1 : 0;
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.update", {
|
||||
agentId: "test-agent",
|
||||
model: "gpt-5.5",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectNotFoundResponseAndNoWrite(respond);
|
||||
});
|
||||
|
||||
it("ensures workspace when workspace changes", async () => {
|
||||
const { promise } = makeCall("agents.update", {
|
||||
agentId: "test-agent",
|
||||
@@ -1132,6 +1165,22 @@ describe("agents.delete", () => {
|
||||
expectNotFoundResponseAndNoWrite(respond);
|
||||
});
|
||||
|
||||
it("returns not found when a concurrent delete wins the delete race", async () => {
|
||||
let findCallCount = 0;
|
||||
mocks.findAgentEntryIndex.mockImplementation(() => {
|
||||
findCallCount += 1;
|
||||
return findCallCount >= 2 ? -1 : 0;
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.delete", {
|
||||
agentId: "test-agent",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expectNotFoundResponseAndNoWrite(respond);
|
||||
expect(mocks.movePathToTrash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid params (missing agentId)", async () => {
|
||||
const { respond, promise } = makeCall("agents.delete", {});
|
||||
await promise;
|
||||
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
import { listAgentsForGateway } from "../session-utils.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type AgentDeleteMutationResult = {
|
||||
workspaceDir: string;
|
||||
agentDir: string;
|
||||
sessionsDir: string;
|
||||
removedBindings: number;
|
||||
};
|
||||
|
||||
const BOOTSTRAP_FILE_NAMES = [
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
DEFAULT_SOUL_FILENAME,
|
||||
@@ -291,6 +298,35 @@ function respondAgentNotFound(respond: RespondFn, agentId: string): void {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `agent "${agentId}" not found`));
|
||||
}
|
||||
|
||||
class AgentConfigPreconditionError extends Error {
|
||||
constructor(
|
||||
readonly kind: "already-exists" | "not-found",
|
||||
readonly agentId: string,
|
||||
) {
|
||||
super(
|
||||
kind === "already-exists"
|
||||
? `agent "${agentId}" already exists`
|
||||
: `agent "${agentId}" not found`,
|
||||
);
|
||||
this.name = "AgentConfigPreconditionError";
|
||||
}
|
||||
}
|
||||
|
||||
function respondAgentConfigPreconditionError(
|
||||
respond: RespondFn,
|
||||
error: AgentConfigPreconditionError,
|
||||
): void {
|
||||
if (error.kind === "not-found") {
|
||||
respondAgentNotFound(respond, error.agentId);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${error.agentId}" already exists`),
|
||||
);
|
||||
}
|
||||
|
||||
async function moveToTrashBestEffort(pathname: string): Promise<void> {
|
||||
if (!pathname) {
|
||||
return;
|
||||
@@ -546,11 +582,12 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mutateConfigFileWithRetry({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: (draft) => {
|
||||
if (findAgentEntryIndex(listAgentEntries(draft), agentId) >= 0) {
|
||||
throw new Error(`agent "${agentId}" already exists`);
|
||||
throw new AgentConfigPreconditionError("already-exists", agentId);
|
||||
}
|
||||
const latestNextConfig = applyAgentConfig(draft, {
|
||||
agentId,
|
||||
@@ -563,6 +600,13 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
Object.assign(draft, latestNextConfig);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentConfigPreconditionError) {
|
||||
respondAgentConfigPreconditionError(respond, error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
respond(true, { ok: true, agentId, name: safeName, workspace: workspaceDir, model }, undefined);
|
||||
},
|
||||
@@ -651,11 +695,12 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mutateConfigFileWithRetry({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: (draft) => {
|
||||
if (!isConfiguredAgent(draft, agentId)) {
|
||||
throw new Error(`agent "${agentId}" not found`);
|
||||
throw new AgentConfigPreconditionError("not-found", agentId);
|
||||
}
|
||||
const latestNextConfig = applyAgentConfig(draft, {
|
||||
agentId,
|
||||
@@ -667,6 +712,13 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
Object.assign(draft, latestNextConfig);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentConfigPreconditionError) {
|
||||
respondAgentConfigPreconditionError(respond, error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
respond(true, { ok: true, agentId }, undefined);
|
||||
},
|
||||
@@ -692,11 +744,13 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true;
|
||||
const committed = await mutateConfigFileWithRetry({
|
||||
let committed: Awaited<ReturnType<typeof mutateConfigFileWithRetry<AgentDeleteMutationResult>>>;
|
||||
try {
|
||||
committed = await mutateConfigFileWithRetry({
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate: (draft) => {
|
||||
if (!isConfiguredAgent(draft, agentId)) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
throw new AgentConfigPreconditionError("not-found", agentId);
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(draft, agentId);
|
||||
const agentDir = resolveAgentDir(draft, agentId);
|
||||
@@ -711,6 +765,13 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AgentConfigPreconditionError) {
|
||||
respondAgentConfigPreconditionError(respond, error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const deleteResult = committed.result;
|
||||
if (!deleteResult) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "agent delete did not commit"));
|
||||
|
||||
Reference in New Issue
Block a user