mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(memory): fence brokered runtime during gateway updates
This commit is contained in:
@@ -1815,6 +1815,13 @@ allow.
|
||||
restart, upgrade, backup, and recovery to this broker lifecycle. A restart
|
||||
rotates its epoch and rejects every pre-restart envelope; Gateway must
|
||||
re-authorize rather than reuse a continuation.
|
||||
- For an unsupervised direct Gateway source update, Gateway takes the broker
|
||||
lifecycle writer lease, fences and retires every selected broker before it
|
||||
mutates the package tree, then keeps memory unavailable in that old process.
|
||||
Core update and post-core plugin convergence share that fenced window; only
|
||||
the replacement Gateway can start a child with a new epoch and secret. A
|
||||
managed-service handoff instead waits for Gateway shutdown before its helper
|
||||
mutates the install root.
|
||||
- Run agent processes in per-session sandboxes with only authorized virtual
|
||||
mounts. The agent never receives the broker database handle or real artifact
|
||||
root.
|
||||
|
||||
@@ -77,6 +77,9 @@ type PostCoreFinalizeOutcome = Awaited<
|
||||
const runPostCoreFinalizeAfterGatewayUpdateMock = vi.fn<() => Promise<PostCoreFinalizeOutcome>>(
|
||||
async () => ({ status: "skipped", reason: "not-git-update" }),
|
||||
);
|
||||
const withBrokeredMemoryUpgradeMock = vi.fn(
|
||||
async <T>(run: () => Promise<T>): Promise<T> => await run(),
|
||||
);
|
||||
|
||||
type UpdateRunPayload = {
|
||||
ok: boolean;
|
||||
@@ -187,6 +190,10 @@ vi.mock("../../infra/update-runner.js", () => ({
|
||||
runGatewayUpdate: runGatewayUpdateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/memory-broker-runtime.js", () => ({
|
||||
withBrokeredMemoryUpgrade: withBrokeredMemoryUpgradeMock,
|
||||
}));
|
||||
|
||||
// Keep the real `foldPostCoreFinalizeIntoResult` so the restart-gate behavior on
|
||||
// finalize failure is exercised; only stub the subprocess-spawning finalizer.
|
||||
vi.mock("../../infra/update-post-core-finalize.js", async () => {
|
||||
@@ -312,6 +319,10 @@ beforeEach(() => {
|
||||
status: "skipped",
|
||||
reason: "not-git-update",
|
||||
});
|
||||
withBrokeredMemoryUpgradeMock.mockClear();
|
||||
withBrokeredMemoryUpgradeMock.mockImplementation(
|
||||
async <T>(run: () => Promise<T>): Promise<T> => await run(),
|
||||
);
|
||||
});
|
||||
|
||||
async function invokeUpdateRun(
|
||||
@@ -982,6 +993,37 @@ describe("update.run post-core plugin finalize", () => {
|
||||
expect(payload?.result?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("fences brokered memory around the full unsupervised git update lifecycle", async () => {
|
||||
const order: string[] = [];
|
||||
withBrokeredMemoryUpgradeMock.mockImplementationOnce(async <T>(run: () => Promise<T>) => {
|
||||
order.push("upgrade:start");
|
||||
const result = await run();
|
||||
order.push("upgrade:finish");
|
||||
return result;
|
||||
});
|
||||
runGatewayUpdateMock.mockImplementationOnce(async () => {
|
||||
order.push("core:update");
|
||||
return {
|
||||
status: "ok",
|
||||
mode: "git",
|
||||
root: "/tmp/openclaw-git",
|
||||
after: { version: "2026.6.1" },
|
||||
steps: [],
|
||||
durationMs: 100,
|
||||
};
|
||||
});
|
||||
runPostCoreFinalizeAfterGatewayUpdateMock.mockImplementationOnce(async () => {
|
||||
order.push("plugin:finalize");
|
||||
return { status: "ok", entrypoint: "/tmp/openclaw-git/dist/index.mjs" };
|
||||
});
|
||||
mockGitInstallSurface("/tmp/openclaw-git");
|
||||
|
||||
await captureUpdateRunPayload();
|
||||
|
||||
expect(withBrokeredMemoryUpgradeMock).toHaveBeenCalledOnce();
|
||||
expect(order).toEqual(["upgrade:start", "core:update", "plugin:finalize", "upgrade:finish"]);
|
||||
});
|
||||
|
||||
it("carries the pre-doctor source config into the git finalizer", async () => {
|
||||
const preUpdateConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -506,41 +506,51 @@ export const updateHandlers: GatewayRequestHandlers = {
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
const runDirectGatewayUpdate = async () => {
|
||||
// The package tree can change beneath this process. Retire selected-memory children
|
||||
// first, then keep this Gateway fenced until its replacement owns a fresh broker epoch.
|
||||
result = await runGatewayUpdate({
|
||||
timeoutMs,
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
channel:
|
||||
installSurface.kind === "git"
|
||||
? (configChannel ?? undefined)
|
||||
: effectiveChannel === "extended-stable"
|
||||
? effectiveChannel
|
||||
: (configChannel ?? undefined),
|
||||
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
|
||||
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
|
||||
allowGatewayServiceRepair: false,
|
||||
allowGatewayActivation: false,
|
||||
});
|
||||
// The CLI `openclaw update` resumes post-core plugin convergence after a
|
||||
// git/source core update; the RPC path did not, leaving official managed
|
||||
// plugins stale on the new core. Run the finalizer here to match.
|
||||
const finalizeOutcome = await runPostCoreFinalizeAfterGatewayUpdate({
|
||||
result,
|
||||
channel: configChannel ?? undefined,
|
||||
serviceRepairPolicy: "external",
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
...(preUpdateConfig ? { preUpdateConfig } : {}),
|
||||
});
|
||||
if (finalizeOutcome.status === "error") {
|
||||
context?.logGateway?.warn(
|
||||
`update.run post-core plugin finalize failed ${formatControlPlaneActor(actor)} reason=${finalizeOutcome.reason}`,
|
||||
);
|
||||
}
|
||||
return foldPostCoreFinalizeIntoResult(result, finalizeOutcome);
|
||||
};
|
||||
// Supervised Windows gateways, including Startup-folder fallbacks, take
|
||||
// the detached handoff above. This direct path is unsupervised, so keep
|
||||
// doctor service mutation disabled: it could rewrite or terminate the
|
||||
// RPC server before the response and restart sentinel become durable.
|
||||
result = await runGatewayUpdate({
|
||||
timeoutMs,
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
channel:
|
||||
installSurface.kind === "git"
|
||||
? (configChannel ?? undefined)
|
||||
: effectiveChannel === "extended-stable"
|
||||
? effectiveChannel
|
||||
: (configChannel ?? undefined),
|
||||
...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}),
|
||||
...(adoptedDevTarget ? { devTarget: adoptedDevTarget } : {}),
|
||||
allowGatewayServiceRepair: false,
|
||||
allowGatewayActivation: false,
|
||||
});
|
||||
// The CLI `openclaw update` resumes post-core plugin convergence after a
|
||||
// git/source core update; the RPC path did not, leaving official managed
|
||||
// plugins stale on the new core. Run the finalizer here to match.
|
||||
const finalizeOutcome = await runPostCoreFinalizeAfterGatewayUpdate({
|
||||
result,
|
||||
channel: configChannel ?? undefined,
|
||||
serviceRepairPolicy: "external",
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
...(preUpdateConfig ? { preUpdateConfig } : {}),
|
||||
});
|
||||
if (finalizeOutcome.status === "error") {
|
||||
context?.logGateway?.warn(
|
||||
`update.run post-core plugin finalize failed ${formatControlPlaneActor(actor)} reason=${finalizeOutcome.reason}`,
|
||||
);
|
||||
}
|
||||
result = foldPostCoreFinalizeIntoResult(result, finalizeOutcome);
|
||||
result =
|
||||
installSurface.kind === "git"
|
||||
? await (
|
||||
await import("../../plugins/memory-broker-runtime.js")
|
||||
).withBrokeredMemoryUpgrade(runDirectGatewayUpdate)
|
||||
: await runDirectGatewayUpdate();
|
||||
}
|
||||
} catch {
|
||||
result = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
startBrokeredMemoryRuntimeSupervisor,
|
||||
testing,
|
||||
withBrokeredMemoryMaintenance,
|
||||
withBrokeredMemoryUpgrade,
|
||||
} from "./memory-broker-runtime.js";
|
||||
import type { MemoryPluginCapability } from "./registry-contribution-types.js";
|
||||
|
||||
@@ -45,6 +46,7 @@ const brokerCapability = {
|
||||
|
||||
afterEach(async () => {
|
||||
await closeBrokeredMemoryRuntimes();
|
||||
testing.clearBrokeredMemoryUpgradeFenceForTest();
|
||||
startMemoryBrokerProcess.mockReset();
|
||||
});
|
||||
|
||||
@@ -242,4 +244,32 @@ describe("brokered memory maintenance", () => {
|
||||
await Promise.all([supervisor, maintenance]);
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retires the broker before direct Gateway update work and keeps the old process fenced", async () => {
|
||||
const order: string[] = [];
|
||||
const broker = {
|
||||
...brokerProcess(),
|
||||
close: vi.fn(async () => {
|
||||
order.push("broker:closed");
|
||||
}),
|
||||
};
|
||||
startMemoryBrokerProcess.mockResolvedValueOnce(broker);
|
||||
await startBrokeredMemoryRuntimeSupervisor(brokerCapability);
|
||||
|
||||
const update = vi.fn(async () => {
|
||||
order.push("update");
|
||||
});
|
||||
await withBrokeredMemoryUpgrade(update);
|
||||
|
||||
expect(order).toEqual(["broker:closed", "update"]);
|
||||
expect(broker.close).toHaveBeenCalledOnce();
|
||||
|
||||
// Shutdown cannot reopen a broker after the old Gateway has started replacing its own code.
|
||||
// The following readiness attempt models a late lifecycle callback in that same process.
|
||||
await closeBrokeredMemoryRuntimes();
|
||||
await expect(startBrokeredMemoryRuntimeSupervisor(brokerCapability)).rejects.toThrow(
|
||||
"selected memory broker did not become ready",
|
||||
);
|
||||
expect(startMemoryBrokerProcess).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
type BrokerRuntimeState = {
|
||||
processes: Map<string, Promise<MemoryBrokerProcess>>;
|
||||
agentIdsByModule: Map<string, readonly string[]>;
|
||||
unavailableAfterUpgrade: Set<string>;
|
||||
supervisors: Set<MemoryBrokerSupervisor>;
|
||||
leases: Map<string, Promise<void>>;
|
||||
maintenance: BrokerMaintenanceGate;
|
||||
@@ -47,6 +48,7 @@ const state = resolveGlobalSingleton(
|
||||
(): BrokerRuntimeState => ({
|
||||
processes: new Map(),
|
||||
agentIdsByModule: new Map(),
|
||||
unavailableAfterUpgrade: new Set(),
|
||||
supervisors: new Set(),
|
||||
leases: new Map(),
|
||||
maintenance: createBrokerMaintenanceGate(),
|
||||
@@ -252,6 +254,11 @@ async function resolveProcess(
|
||||
}
|
||||
|
||||
async function resolveProcessOnLease(moduleUrl: string): Promise<MemoryBrokerProcess | undefined> {
|
||||
// The Gateway may update its own package tree while it is still answering the update RPC. Do
|
||||
// not fork a child from a half-replaced tree: only the replacement Gateway may clear this fence.
|
||||
if (state.unavailableAfterUpgrade.has(moduleUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
const existing = state.processes.get(moduleUrl);
|
||||
if (existing) {
|
||||
try {
|
||||
@@ -328,7 +335,10 @@ export async function startBrokeredMemoryRuntimeSupervisor(
|
||||
if (!moduleUrl) {
|
||||
return undefined;
|
||||
}
|
||||
state.agentIdsByModule.set(moduleUrl, Object.freeze([...new Set(params.agentIds ?? [])].toSorted()));
|
||||
state.agentIdsByModule.set(
|
||||
moduleUrl,
|
||||
Object.freeze([...new Set(params.agentIds ?? [])].toSorted()),
|
||||
);
|
||||
const supervisor = await startMemoryBrokerSupervisor({
|
||||
ensureProcess: () => resolveProcess(capability),
|
||||
retireProcess: () => retireProcess(capability),
|
||||
@@ -586,6 +596,28 @@ export async function closeBrokeredMemoryRuntimes(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-process Gateway update must retire every selected broker before it rewrites code or
|
||||
* plugins. The current process stays memory-unavailable afterward; only its replacement may
|
||||
* create a child, run startup recovery, and mint a new epoch/secret.
|
||||
*/
|
||||
export async function withBrokeredMemoryUpgrade<T>(run: () => Promise<T>): Promise<T> {
|
||||
return await withGatewayBrokeredMemoryMaintenanceLease(state.maintenance, async () => {
|
||||
const moduleUrls = [
|
||||
...new Set([...state.processes.keys(), ...state.agentIdsByModule.keys()]),
|
||||
].toSorted();
|
||||
for (const moduleUrl of moduleUrls) {
|
||||
await withBrokerLease(state.leases, moduleUrl, async () => {
|
||||
// Fence resolution before closing the old child. A failed close must abort the update;
|
||||
// proceeding could leave an old broker serving from code the update is about to replace.
|
||||
state.unavailableAfterUpgrade.add(moduleUrl);
|
||||
await retireProcessOnLease(state, moduleUrl);
|
||||
});
|
||||
}
|
||||
return await run();
|
||||
});
|
||||
}
|
||||
|
||||
type BrokerMaintenanceProcess = Pick<MemoryBrokerProcess, "isRunning" | "quiesce" | "resume">;
|
||||
|
||||
async function runBrokeredMemoryMaintenance<T>(params: {
|
||||
@@ -679,6 +711,7 @@ export async function withBrokeredMemoryMaintenance<T>(run: () => Promise<T>): P
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
clearBrokeredMemoryUpgradeFenceForTest: () => state.unavailableAfterUpgrade.clear(),
|
||||
createBrokerMaintenanceGate,
|
||||
withBrokerLease,
|
||||
withBrokerLifecycleOperation,
|
||||
|
||||
Reference in New Issue
Block a user