mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
f59e945013
* chore: preserve session placement move work * feat(gateway): move sessions between runners * test(gateway): align recovery fixture with move barriers * fix(protocol): format literal-only Swift initializers * test(ui): prove session move terminal state * refactor(gateway): narrow session move target fence * fix(gateway): preserve pending result recovery ownership * test(workers): migrate reclaim lifecycle fixtures * refactor(gateway): keep move internals private * test(gateway): align split bootstrap fixture with moves * fix(gateway): recover session moves through exact intent * refactor(gateway): derive move placement subtypes locally
399 lines
16 KiB
TypeScript
399 lines
16 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { NODE_WORKER_WORKSPACE_RETAIN_COMMAND } from "../../../../src/infra/node-commands.js";
|
|
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
|
|
import { PROOF_TIMEOUT_MS } from "./cloud-worker-midturn-loss-fixture.js";
|
|
import { startPairedNodeWorkerLifecycleProvider } from "./paired-node-worker-lifecycle-provider.js";
|
|
import {
|
|
bundleInstallFrames,
|
|
closeWireServer,
|
|
connectWireClient,
|
|
createPairedNodeWorkerHost,
|
|
createPublishedWireWorkspace,
|
|
startPairedNodeWorkerGateway,
|
|
type PairedNodeWorkerHost,
|
|
type PublishedWireWorkspace,
|
|
type WireGateway,
|
|
type WireNodeRead,
|
|
wireMessageText,
|
|
} from "./paired-node-worker-wire-fixture.js";
|
|
|
|
const TEST_TIMEOUT_MS = PROOF_TIMEOUT_MS + 180_000;
|
|
const SESSION_PREFIX = "agent:qa:paired-node-worker-lifecycle";
|
|
const HOLD_A = "WIRE-HOLD-A";
|
|
const HOLD_B = "WIRE-HOLD-B";
|
|
|
|
type TurnResult = { runId?: string; status?: string; summary?: string };
|
|
type Placement = {
|
|
activeOwnerEpoch?: number;
|
|
environmentId?: string;
|
|
generation?: number;
|
|
state?: string;
|
|
workerBundleHash?: string;
|
|
};
|
|
type EnvironmentRead = {
|
|
id: string;
|
|
type: string;
|
|
workerBundle?: WireNodeRead["workerBundle"];
|
|
worker?: { state?: string };
|
|
};
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
|
|
async function createSession(params: {
|
|
operator: GatewayClient;
|
|
published: PublishedWireWorkspace;
|
|
suffix: string;
|
|
}): Promise<string> {
|
|
const key = `${SESSION_PREFIX}:${params.suffix}`;
|
|
await params.operator.request("sessions.create", {
|
|
key,
|
|
agentId: "qa",
|
|
worktree: true,
|
|
worktreeName: `paired-node-${params.suffix}`,
|
|
worktreeBaseRef: "main",
|
|
cwd: params.published.source,
|
|
});
|
|
return key;
|
|
}
|
|
|
|
async function dispatchNodeSession(params: {
|
|
gateway: WireGateway;
|
|
key: string;
|
|
nodeId: string;
|
|
}): Promise<Placement> {
|
|
const result = (await params.gateway.call(
|
|
"sessions.dispatch",
|
|
{ key: params.key, deviceId: params.nodeId },
|
|
{ timeoutMs: PROOF_TIMEOUT_MS },
|
|
)) as { placement?: Placement };
|
|
expect(result.placement).toMatchObject({ state: "active" });
|
|
return result.placement!;
|
|
}
|
|
|
|
async function startTurn(params: {
|
|
operator: GatewayClient;
|
|
key: string;
|
|
marker: string;
|
|
}): Promise<string> {
|
|
const runId = `${params.marker.toLowerCase()}-${Date.now()}`;
|
|
const started = await params.operator.request<TurnResult>("chat.send", {
|
|
sessionKey: params.key,
|
|
message: `Reply exactly: ${params.marker}`,
|
|
deliver: false,
|
|
idempotencyKey: runId,
|
|
});
|
|
expect(started).toMatchObject({ runId, status: "started" });
|
|
return runId;
|
|
}
|
|
|
|
async function waitForTurn(operator: GatewayClient, runId: string): Promise<TurnResult> {
|
|
return await operator.request<TurnResult>(
|
|
"agent.wait",
|
|
{ runId, timeoutMs: PROOF_TIMEOUT_MS },
|
|
{ timeoutMs: PROOF_TIMEOUT_MS + 5_000 },
|
|
);
|
|
}
|
|
|
|
async function expectSuccessfulTurn(params: {
|
|
operator: GatewayClient;
|
|
key: string;
|
|
marker: string;
|
|
}): Promise<void> {
|
|
const runId = await startTurn(params);
|
|
await expect(waitForTurn(params.operator, runId)).resolves.toMatchObject({ status: "ok" });
|
|
await vi.waitFor(
|
|
async () => {
|
|
const history = await params.operator.request<{ messages?: unknown[] }>("chat.history", {
|
|
sessionKey: params.key,
|
|
limit: 100,
|
|
});
|
|
expect(
|
|
history.messages?.some(
|
|
(message) =>
|
|
(message as { role?: unknown }).role === "assistant" &&
|
|
wireMessageText(message).includes(params.marker),
|
|
),
|
|
).toBe(true);
|
|
},
|
|
{ timeout: 30_000, interval: 100 },
|
|
);
|
|
}
|
|
|
|
async function describePlacement(gateway: WireGateway, key: string): Promise<Placement> {
|
|
const described = (await gateway.call("sessions.describe", { key })) as {
|
|
session?: { placement?: Placement };
|
|
};
|
|
return described.session?.placement ?? {};
|
|
}
|
|
|
|
async function readNode(
|
|
operator: GatewayClient,
|
|
nodeId: string,
|
|
): Promise<WireNodeRead | undefined> {
|
|
const result = await operator.request<{ nodes?: WireNodeRead[] }>("node.list", {});
|
|
return result.nodes?.find((node) => node.nodeId === nodeId);
|
|
}
|
|
|
|
async function readEnvironments(operator: GatewayClient): Promise<EnvironmentRead[]> {
|
|
const result = await operator.request<{ environments?: EnvironmentRead[] }>(
|
|
"environments.list",
|
|
{},
|
|
);
|
|
return result.environments ?? [];
|
|
}
|
|
|
|
async function expectPublicBundleStatus(params: {
|
|
operator: GatewayClient;
|
|
nodeId: string;
|
|
status: "installed" | "missing";
|
|
}): Promise<void> {
|
|
await vi.waitFor(
|
|
async () => {
|
|
const node = await readNode(params.operator, params.nodeId);
|
|
const environment = (await readEnvironments(params.operator)).find(
|
|
(entry) => entry.id === `node:${params.nodeId}`,
|
|
);
|
|
expect(node?.workerBundle?.status).toBe(params.status);
|
|
expect(environment?.workerBundle?.status).toBe(params.status);
|
|
for (const projection of [node?.workerBundle, environment?.workerBundle]) {
|
|
expect(Object.keys(projection ?? {}).toSorted()).toEqual(
|
|
params.status === "installed" ? ["status", "version"] : ["status"],
|
|
);
|
|
expect(projection).not.toHaveProperty("bundleHash");
|
|
expect(projection).not.toHaveProperty("path");
|
|
}
|
|
},
|
|
{ timeout: 30_000, interval: 100 },
|
|
);
|
|
}
|
|
|
|
describe("paired node worker lifecycle wire", () => {
|
|
it(
|
|
"keeps local control usable across bundle loss, disconnect, capacity, and role removal",
|
|
{ timeout: TEST_TIMEOUT_MS },
|
|
async () => {
|
|
const root = tempDirs.make("openclaw-paired-node-worker-lifecycle-");
|
|
const provider = await startPairedNodeWorkerLifecycleProvider([HOLD_A, HOLD_B]);
|
|
const published = await createPublishedWireWorkspace(root);
|
|
let gateway: WireGateway | undefined;
|
|
let operator: GatewayClient | undefined;
|
|
let workerNode: PairedNodeWorkerHost | undefined;
|
|
let testFailure: { error: unknown } | undefined;
|
|
let cleanupFailures: unknown[];
|
|
try {
|
|
gateway = await startPairedNodeWorkerGateway({ providerBaseUrl: provider.baseUrl });
|
|
operator = await connectWireClient({ gateway, role: "operator", identity: null });
|
|
workerNode = await createPairedNodeWorkerHost({
|
|
gateway,
|
|
operator,
|
|
root,
|
|
capacity: 2,
|
|
capacityWaitMs: 750,
|
|
bundlePrewarm: true,
|
|
bundleRetention: true,
|
|
bundleStatus: true,
|
|
});
|
|
const nodeId = workerNode.identity.deviceId;
|
|
const localKey = await createSession({ operator, published, suffix: "local-control" });
|
|
const retainedKey = await createSession({ operator, published, suffix: "retention" });
|
|
const retainedPlacement = await dispatchNodeSession({
|
|
gateway,
|
|
key: retainedKey,
|
|
nodeId,
|
|
});
|
|
const bundleHash = retainedPlacement.workerBundleHash;
|
|
expect(bundleHash).toMatch(/^[a-f0-9]{64}$/u);
|
|
|
|
// Local and node-placed sessions share the Gateway without sharing failure state.
|
|
await expectSuccessfulTurn({ operator, key: retainedKey, marker: "WIRE-NODE-BASELINE" });
|
|
await expectSuccessfulTurn({ operator, key: localKey, marker: "WIRE-LOCAL-BASELINE" });
|
|
|
|
// Stop-and-continue moves reconcile the source before returning local, never pass through
|
|
// reclaimed, and the same session can dispatch back to its paired node afterward.
|
|
const sourcePlacement = await describePlacement(gateway, retainedKey);
|
|
expect(sourcePlacement).toMatchObject({
|
|
state: "active",
|
|
environmentId: retainedPlacement.environmentId,
|
|
generation: expect.any(Number),
|
|
activeOwnerEpoch: expect.any(Number),
|
|
});
|
|
if (
|
|
typeof sourcePlacement.generation !== "number" ||
|
|
typeof sourcePlacement.environmentId !== "string" ||
|
|
typeof sourcePlacement.activeOwnerEpoch !== "number"
|
|
) {
|
|
throw new Error("active placement did not expose exact move source facts");
|
|
}
|
|
const movedLocal = (await gateway.call(
|
|
"sessions.move",
|
|
{
|
|
key: retainedKey,
|
|
expected: {
|
|
generation: sourcePlacement.generation,
|
|
environmentId: sourcePlacement.environmentId,
|
|
ownerEpoch: sourcePlacement.activeOwnerEpoch,
|
|
},
|
|
target: { kind: "gateway" },
|
|
},
|
|
{ timeoutMs: PROOF_TIMEOUT_MS },
|
|
)) as { placement?: Placement };
|
|
expect(movedLocal.placement).toMatchObject({ state: "local" });
|
|
expect(await describePlacement(gateway, retainedKey)).toMatchObject({ state: "local" });
|
|
await expectSuccessfulTurn({ operator, key: retainedKey, marker: "WIRE-MOVED-LOCAL" });
|
|
await dispatchNodeSession({ gateway, key: retainedKey, nodeId });
|
|
await expectSuccessfulTurn({ operator, key: retainedKey, marker: "WIRE-MOVED-BACK" });
|
|
|
|
// Bundle maintenance reports only the public status/version projection and repairs loss.
|
|
await expectPublicBundleStatus({ operator, nodeId, status: "installed" });
|
|
const installedBundle = await workerNode.installedBundleDirectory(bundleHash!);
|
|
const installCountBeforeLoss = bundleInstallFrames(workerNode).length;
|
|
await fs.rm(installedBundle, { recursive: true, force: true });
|
|
await workerNode.disconnect();
|
|
await workerNode.connect();
|
|
await expectPublicBundleStatus({ operator, nodeId, status: "missing" });
|
|
expect(
|
|
workerNode.commands.filter((command) => command === NODE_WORKER_WORKSPACE_RETAIN_COMMAND)
|
|
.length,
|
|
).toBeGreaterThan(0);
|
|
await expectSuccessfulTurn({ operator, key: localKey, marker: "WIRE-LOCAL-AFTER-LOSS" });
|
|
|
|
const repairedKey = await createSession({ operator, published, suffix: "reinstalled" });
|
|
await dispatchNodeSession({ gateway, key: repairedKey, nodeId });
|
|
await vi.waitFor(
|
|
() => expect(bundleInstallFrames(workerNode!).length).toBe(installCountBeforeLoss + 1),
|
|
{ timeout: 30_000, interval: 100 },
|
|
);
|
|
await expectPublicBundleStatus({ operator, nodeId, status: "installed" });
|
|
|
|
// An offline runner fails before handoff, leaves the active placement retryable, and
|
|
// does not terminalize the independent local session.
|
|
await workerNode.disconnect();
|
|
const offlineRunId = await startTurn({
|
|
operator,
|
|
key: repairedKey,
|
|
marker: "WIRE-OFFLINE-ATTEMPT",
|
|
});
|
|
const offline = await waitForTurn(operator, offlineRunId);
|
|
expect(offline.status).not.toBe("ok");
|
|
expect(`${offline.summary ?? ""} ${JSON.stringify(offline)}`).toMatch(
|
|
/runner-offline|runner is offline|reconnect/iu,
|
|
);
|
|
expect(await describePlacement(gateway, repairedKey)).toMatchObject({ state: "active" });
|
|
await expectSuccessfulTurn({ operator, key: localKey, marker: "WIRE-LOCAL-AFTER-OFFLINE" });
|
|
await workerNode.connect();
|
|
await expectSuccessfulTurn({ operator, key: repairedKey, marker: "WIRE-OFFLINE-RETRY" });
|
|
await workerNode.waitForWorkersIdle();
|
|
|
|
// Two nonterminal real worker children consume both physical slots. The third turn
|
|
// records a bounded capacity failure at the public RPC/history/placement boundaries.
|
|
const holdAKey = await createSession({ operator, published, suffix: "capacity-a" });
|
|
const holdBKey = await createSession({ operator, published, suffix: "capacity-b" });
|
|
const capacityKey = await createSession({ operator, published, suffix: "capacity-c" });
|
|
await dispatchNodeSession({ gateway, key: holdAKey, nodeId });
|
|
await dispatchNodeSession({ gateway, key: holdBKey, nodeId });
|
|
await dispatchNodeSession({ gateway, key: capacityKey, nodeId });
|
|
const holdARunId = await startTurn({ operator, key: holdAKey, marker: HOLD_A });
|
|
const holdBRunId = await startTurn({ operator, key: holdBKey, marker: HOLD_B });
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(provider.hasHeld(HOLD_A)).toBe(true);
|
|
expect(provider.hasHeld(HOLD_B)).toBe(true);
|
|
},
|
|
{ timeout: 30_000, interval: 100 },
|
|
);
|
|
const capacityRunId = await startTurn({
|
|
operator,
|
|
key: capacityKey,
|
|
marker: "WIRE-CAPACITY-ATTEMPT",
|
|
});
|
|
const capacity = await waitForTurn(operator, capacityRunId);
|
|
expect(capacity.status).not.toBe("ok");
|
|
expect(`${capacity.summary ?? ""} ${JSON.stringify(capacity)}`).toMatch(/capacity/iu);
|
|
expect(await describePlacement(gateway, capacityKey)).toMatchObject({ state: "active" });
|
|
await vi.waitFor(
|
|
async () => {
|
|
const history = await operator!.request<{ messages?: unknown[] }>("chat.history", {
|
|
sessionKey: capacityKey,
|
|
limit: 100,
|
|
});
|
|
const messages = history.messages ?? [];
|
|
expect(
|
|
messages.some(
|
|
(message) =>
|
|
(message as { role?: unknown }).role === "user" &&
|
|
wireMessageText(message).includes("WIRE-CAPACITY-ATTEMPT"),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
messages.some(
|
|
(message) =>
|
|
(message as { role?: unknown }).role === "assistant" &&
|
|
wireMessageText(message).includes("WIRE-CAPACITY-ATTEMPT"),
|
|
),
|
|
).toBe(false);
|
|
},
|
|
{ timeout: 30_000, interval: 100 },
|
|
);
|
|
await expectSuccessfulTurn({ operator, key: localKey, marker: "WIRE-LOCAL-AT-CAPACITY" });
|
|
provider.release(HOLD_A);
|
|
await expect(waitForTurn(operator, holdARunId)).resolves.toMatchObject({ status: "ok" });
|
|
await expectSuccessfulTurn({
|
|
operator,
|
|
key: capacityKey,
|
|
marker: "WIRE-CAPACITY-RETRY",
|
|
});
|
|
provider.release(HOLD_B);
|
|
await expect(waitForTurn(operator, holdBRunId)).resolves.toMatchObject({ status: "ok" });
|
|
|
|
// Node-role removal waits for environment and placement cleanup before returning,
|
|
// invalidates the old connection, and leaves local execution available.
|
|
const removalKey = await createSession({ operator, published, suffix: "role-removal" });
|
|
const removalPlacement = await dispatchNodeSession({ gateway, key: removalKey, nodeId });
|
|
expect(workerNode.client).toBeTruthy();
|
|
await expect(operator.request("node.pair.remove", { nodeId })).resolves.toMatchObject({
|
|
nodeId,
|
|
});
|
|
const removedEnvironment = (await readEnvironments(operator)).find(
|
|
(entry) => entry.id === removalPlacement.environmentId,
|
|
);
|
|
expect(["destroyed", "failed", "orphaned"]).toContain(removedEnvironment?.worker?.state);
|
|
expect(await describePlacement(gateway, removalKey)).toMatchObject({ state: "failed" });
|
|
await expect(workerNode.publishInventory()).rejects.toBeTruthy();
|
|
await vi.waitFor(
|
|
async () => {
|
|
const node = await readNode(operator!, nodeId);
|
|
expect(node?.connected).not.toBe(true);
|
|
},
|
|
{ timeout: 30_000, interval: 100 },
|
|
);
|
|
await expectSuccessfulTurn({ operator, key: localKey, marker: "WIRE-LOCAL-AFTER-REMOVAL" });
|
|
await workerNode.waitForInvokes();
|
|
expect(workerNode.invokeErrors).toEqual([]);
|
|
} catch (error) {
|
|
testFailure = { error };
|
|
} finally {
|
|
provider.releaseAll();
|
|
const cleanup = await Promise.allSettled([
|
|
workerNode?.stop() ?? Promise.resolve(),
|
|
operator?.stopAndWait({ timeoutMs: 2_000 }) ?? Promise.resolve(),
|
|
gateway?.stop() ?? Promise.resolve(),
|
|
provider.stop(),
|
|
closeWireServer(published.server),
|
|
]);
|
|
cleanupFailures = cleanup.flatMap((result) =>
|
|
result.status === "rejected" ? [result.reason] : [],
|
|
);
|
|
}
|
|
const failures = [...(testFailure ? [testFailure.error] : []), ...cleanupFailures];
|
|
if (failures.length === 1) {
|
|
throw failures[0];
|
|
}
|
|
if (failures.length > 1) {
|
|
throw new AggregateError(failures, "paired node worker lifecycle test failed");
|
|
}
|
|
},
|
|
);
|
|
});
|