mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:41:15 +00:00
1315 lines
44 KiB
TypeScript
1315 lines
44 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { createServer, type Server } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { Value } from "typebox/value";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { WebSocket, WebSocketServer, type RawData } from "ws";
|
|
import {
|
|
type WorkerConnectRequestFrame,
|
|
WorkerConnectRequestFrameSchema,
|
|
type WorkerHeartbeatRequestFrame,
|
|
WorkerHeartbeatRequestFrameSchema,
|
|
type WorkerLiveEventParams,
|
|
type WorkerLiveEventRequestFrame,
|
|
WorkerLiveEventRequestFrameSchema,
|
|
WORKER_PROTOCOL_FEATURES,
|
|
WORKER_RPC_SET_VERSION,
|
|
type WorkerTranscriptCommitParams,
|
|
type WorkerTranscriptCommitRequestFrame,
|
|
WorkerTranscriptCommitRequestFrameSchema,
|
|
type WorkerTranscriptMessage,
|
|
} from "../../packages/gateway-protocol/src/schema/worker-admission.js";
|
|
import {
|
|
type WorkerInferenceCancelRequestFrame,
|
|
WorkerInferenceCancelRequestFrameSchema,
|
|
type WorkerInferenceEventFrame,
|
|
WORKER_INFERENCE_MAX_CONTEXT_MESSAGES,
|
|
type WorkerInferenceStartParams,
|
|
type WorkerInferenceStartRequestFrame,
|
|
WorkerInferenceStartRequestFrameSchema,
|
|
type WorkerInferenceTerminalFrame,
|
|
type WorkerInferenceTerminalOutcome,
|
|
} from "../../packages/gateway-protocol/src/schema/worker-inference.js";
|
|
import { listRunningSessions } from "../agents/bash-process-registry.js";
|
|
import { rawDataToString } from "../infra/ws.js";
|
|
import { buildWorkerConnectParams, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
|
|
import { WorkerAdmissionDeadlineExceededError } from "./worker-connection-contract.js";
|
|
import { createWorkerConnection, WorkerConnectionStoppedError } from "./worker-connection.js";
|
|
import {
|
|
WorkerInferenceProxyClient,
|
|
WorkerLiveEventClient,
|
|
WorkerTranscriptCommitClient,
|
|
} from "./worker-rpc-clients.js";
|
|
import { runWorkerDescriptor } from "./worker.runtime.js";
|
|
|
|
const SESSION_ID = "worker-session";
|
|
const RUN_ID = "worker-run";
|
|
const OWNER_EPOCH = 4;
|
|
const MODEL_REF = { provider: "openai", model: "gpt-5.6-luna" } as const;
|
|
const BUNDLE_HASH = Array.from({ length: 64 }, () => "a").join("");
|
|
const CREDENTIAL = ["worker", "fixture", "admission"].join("-");
|
|
|
|
type InferencePlan =
|
|
| "text"
|
|
| "tool"
|
|
| "background-tool"
|
|
| "hold"
|
|
| "fence"
|
|
| "error"
|
|
| "cancelled"
|
|
| "burst-text"
|
|
| "oversized-text"
|
|
| "oversized-error"
|
|
| "empty-terminal";
|
|
type WorkerDoneMessage = Extract<WorkerInferenceTerminalOutcome, { type: "done" }>["message"];
|
|
|
|
type FakeGatewayOptions = {
|
|
admissionFailure?: "gateway-unavailable" | "invalid-credential" | "owner-epoch-mismatch";
|
|
inferencePlans?: InferencePlan[];
|
|
outageOnInferenceCancel?: boolean;
|
|
ignoreFirstAdmission?: boolean;
|
|
ignoreHeartbeat?: boolean;
|
|
silenceFirstTranscript?: boolean;
|
|
silenceFirstLiveEvent?: boolean;
|
|
silenceFirstInference?: boolean;
|
|
transcriptFailureAtRequest?: number;
|
|
liveResyncAckedSeq?: number;
|
|
liveResyncResponses?: number;
|
|
liveFailure?: "capacity-exceeded";
|
|
heartbeatFailure?: "credential-expired";
|
|
heartbeatIntervalMs?: number;
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function assistantMessage(
|
|
content: WorkerDoneMessage["content"],
|
|
stopReason: "stop" | "toolUse",
|
|
): WorkerDoneMessage {
|
|
return {
|
|
role: "assistant",
|
|
content,
|
|
api: "openai-responses",
|
|
provider: MODEL_REF.provider,
|
|
model: MODEL_REF.model,
|
|
usage: {
|
|
input: 2,
|
|
output: 3,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 5,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
class FakeWorkerGateway {
|
|
private readonly httpServer: Server;
|
|
private readonly webSocketServer: WebSocketServer;
|
|
private readonly clients = new Set<WebSocket>();
|
|
private rootDir: string | undefined;
|
|
private inferencePlanIndex = 0;
|
|
private droppedTranscript = false;
|
|
private droppedLiveEvent = false;
|
|
private droppedInference = false;
|
|
private sentLiveResync = 0;
|
|
private unavailable = false;
|
|
private ignoredAdmission = false;
|
|
|
|
socketPath = "";
|
|
connectionCount = 0;
|
|
readonly methods: string[] = [];
|
|
readonly transcriptRequests: WorkerTranscriptCommitParams[] = [];
|
|
readonly acceptedTranscriptRequests: WorkerTranscriptCommitParams[] = [];
|
|
readonly liveEventRequests: WorkerLiveEventParams[] = [];
|
|
readonly inferenceRequests: WorkerInferenceStartParams[] = [];
|
|
readonly applicationOrder: string[] = [];
|
|
|
|
constructor(private readonly options: FakeGatewayOptions = {}) {
|
|
this.httpServer = createServer();
|
|
this.webSocketServer = new WebSocketServer({ server: this.httpServer });
|
|
this.webSocketServer.on("connection", (socket) => this.accept(socket));
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-worker-gateway-"));
|
|
this.socketPath = path.join(this.rootDir, "gateway.sock");
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (error: Error) => {
|
|
this.httpServer.off("listening", onListening);
|
|
reject(error);
|
|
};
|
|
const onListening = () => {
|
|
this.httpServer.off("error", onError);
|
|
resolve();
|
|
};
|
|
this.httpServer.once("error", onError);
|
|
this.httpServer.once("listening", onListening);
|
|
this.httpServer.listen(this.socketPath);
|
|
});
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
for (const client of this.clients) {
|
|
client.terminate();
|
|
}
|
|
this.clients.clear();
|
|
await new Promise<void>((resolve) => {
|
|
this.webSocketServer.close(() => resolve());
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
this.httpServer.close(() => resolve());
|
|
});
|
|
if (this.rootDir) {
|
|
await rm(this.rootDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
private accept(socket: WebSocket): void {
|
|
this.connectionCount += 1;
|
|
this.clients.add(socket);
|
|
socket.on("close", () => this.clients.delete(socket));
|
|
socket.on("message", (data: RawData) => this.handleMessage(socket, data));
|
|
}
|
|
|
|
private handleMessage(socket: WebSocket, data: RawData): void {
|
|
const parsed = JSON.parse(rawDataToString(data)) as unknown;
|
|
if (Value.Check(WorkerConnectRequestFrameSchema, parsed)) {
|
|
this.handleConnect(socket, parsed as WorkerConnectRequestFrame);
|
|
return;
|
|
}
|
|
if (Value.Check(WorkerHeartbeatRequestFrameSchema, parsed)) {
|
|
this.handleHeartbeat(socket, parsed as WorkerHeartbeatRequestFrame);
|
|
return;
|
|
}
|
|
if (Value.Check(WorkerTranscriptCommitRequestFrameSchema, parsed)) {
|
|
this.handleTranscript(socket, parsed as WorkerTranscriptCommitRequestFrame);
|
|
return;
|
|
}
|
|
if (Value.Check(WorkerLiveEventRequestFrameSchema, parsed)) {
|
|
this.handleLiveEvent(socket, parsed as WorkerLiveEventRequestFrame);
|
|
return;
|
|
}
|
|
if (Value.Check(WorkerInferenceStartRequestFrameSchema, parsed)) {
|
|
this.handleInference(socket, parsed as WorkerInferenceStartRequestFrame);
|
|
return;
|
|
}
|
|
if (Value.Check(WorkerInferenceCancelRequestFrameSchema, parsed)) {
|
|
this.handleInferenceCancel(socket, parsed as WorkerInferenceCancelRequestFrame);
|
|
return;
|
|
}
|
|
const unsupported: unknown = parsed;
|
|
if (isRecord(unsupported) && typeof unsupported.method === "string") {
|
|
this.methods.push(unsupported.method);
|
|
}
|
|
socket.close(1008, "invalid-frame");
|
|
}
|
|
|
|
private handleConnect(socket: WebSocket, frame: WorkerConnectRequestFrame): void {
|
|
this.methods.push(frame.method);
|
|
if (this.unavailable) {
|
|
socket.terminate();
|
|
return;
|
|
}
|
|
if (this.options.ignoreFirstAdmission && !this.ignoredAdmission) {
|
|
this.ignoredAdmission = true;
|
|
return;
|
|
}
|
|
if (this.options.admissionFailure) {
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: {
|
|
code: "INVALID_REQUEST",
|
|
message: "worker fixture rejected",
|
|
details: { reason: this.options.admissionFailure },
|
|
retryable: true,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: {
|
|
type: "worker-hello-ok",
|
|
environmentId: frame.params.admission.environmentId,
|
|
sessionId: frame.params.admission.sessionId,
|
|
ownerEpoch: frame.params.admission.ownerEpoch,
|
|
rpcSetVersion: frame.params.admission.rpcSetVersion,
|
|
protocolFeatures: [...frame.params.admission.handshake.protocolFeatures],
|
|
credentialExpiresAtMs: Date.now() + 60_000,
|
|
policy: {
|
|
heartbeatIntervalMs: this.options.heartbeatIntervalMs ?? 60_000,
|
|
maxPayload: 25 * 1024 * 1024,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
private handleHeartbeat(socket: WebSocket, frame: WorkerHeartbeatRequestFrame): void {
|
|
this.methods.push(frame.method);
|
|
if (this.options.ignoreHeartbeat) {
|
|
return;
|
|
}
|
|
if (this.options.heartbeatFailure) {
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: {
|
|
code: "INVALID_REQUEST",
|
|
message: "worker heartbeat rejected",
|
|
details: { reason: this.options.heartbeatFailure },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: { receivedAtMs: Date.now(), status: "ok", ownerEpoch: OWNER_EPOCH },
|
|
});
|
|
}
|
|
|
|
private handleInferenceCancel(socket: WebSocket, frame: WorkerInferenceCancelRequestFrame): void {
|
|
this.methods.push(frame.method);
|
|
if (this.options.outageOnInferenceCancel) {
|
|
this.unavailable = true;
|
|
socket.terminate();
|
|
return;
|
|
}
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: { status: "cancelled" },
|
|
});
|
|
}
|
|
|
|
private handleTranscript(socket: WebSocket, frame: WorkerTranscriptCommitRequestFrame): void {
|
|
this.methods.push(frame.method);
|
|
this.transcriptRequests.push(structuredClone(frame.params));
|
|
if (this.options.silenceFirstTranscript && !this.droppedTranscript) {
|
|
this.droppedTranscript = true;
|
|
return;
|
|
}
|
|
if (this.transcriptRequests.length === this.options.transcriptFailureAtRequest) {
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: {
|
|
code: "INVALID_REQUEST",
|
|
message: "worker transcript commit rejected",
|
|
details: { reason: "stale-base-leaf" },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
this.acceptedTranscriptRequests.push(structuredClone(frame.params));
|
|
this.applicationOrder.push(`transcript:${frame.params.seq}`);
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: {
|
|
entryIds: frame.params.messages.map(
|
|
(_message, index) => `entry-${frame.params.seq}-${index}`,
|
|
),
|
|
newLeafId: `leaf-${frame.params.seq}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
private handleLiveEvent(socket: WebSocket, frame: WorkerLiveEventRequestFrame): void {
|
|
this.methods.push(frame.method);
|
|
this.liveEventRequests.push(structuredClone(frame.params));
|
|
this.applicationOrder.push(
|
|
frame.params.event.kind === "lifecycle"
|
|
? `live:lifecycle:${frame.params.event.payload.phase}`
|
|
: `live:${frame.params.event.kind}`,
|
|
);
|
|
if (this.options.silenceFirstLiveEvent && !this.droppedLiveEvent) {
|
|
this.droppedLiveEvent = true;
|
|
return;
|
|
}
|
|
if (
|
|
this.options.liveResyncAckedSeq !== undefined &&
|
|
this.sentLiveResync < (this.options.liveResyncResponses ?? 1)
|
|
) {
|
|
this.sentLiveResync += 1;
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: {
|
|
code: "INVALID_REQUEST",
|
|
message: "worker live event rejected",
|
|
details: {
|
|
reason: "resync-required",
|
|
ackedSeq: this.options.liveResyncAckedSeq,
|
|
expectedSeq: this.options.liveResyncAckedSeq + 1,
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (this.options.liveFailure) {
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: false,
|
|
error: {
|
|
code: "INVALID_REQUEST",
|
|
message: "worker live event rejected",
|
|
details: { reason: this.options.liveFailure },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: { ackedSeq: frame.params.seq },
|
|
});
|
|
}
|
|
|
|
private handleInference(socket: WebSocket, frame: WorkerInferenceStartRequestFrame): void {
|
|
this.methods.push(frame.method);
|
|
this.inferenceRequests.push(structuredClone(frame.params));
|
|
if (this.options.silenceFirstInference && !this.droppedInference) {
|
|
this.droppedInference = true;
|
|
return;
|
|
}
|
|
this.send(socket, {
|
|
type: "res",
|
|
id: frame.id,
|
|
ok: true,
|
|
payload: { status: "accepted" },
|
|
});
|
|
const plan = this.options.inferencePlans?.[this.inferencePlanIndex] ?? "text";
|
|
this.inferencePlanIndex += 1;
|
|
if (plan === "hold") {
|
|
return;
|
|
}
|
|
if (plan === "fence") {
|
|
setTimeout(() => socket.close(1008, "owner-epoch-mismatch"), 5);
|
|
return;
|
|
}
|
|
if (plan === "error" || plan === "cancelled") {
|
|
this.sendTerminalOutcome(socket, frame.params, 1, {
|
|
type: "error",
|
|
reason: plan === "error" ? "provider-error" : "cancelled",
|
|
message: plan === "error" ? "fixture provider failed" : "fixture inference cancelled",
|
|
});
|
|
return;
|
|
}
|
|
if (plan === "tool" || plan === "background-tool") {
|
|
this.sendToolTurn(socket, frame.params, plan === "background-tool");
|
|
return;
|
|
}
|
|
if (plan === "burst-text") {
|
|
this.sendBurstTextTurn(socket, frame.params);
|
|
return;
|
|
}
|
|
if (plan === "oversized-text") {
|
|
this.sendBurstTextTurn(socket, frame.params, 1_700);
|
|
return;
|
|
}
|
|
if (plan === "oversized-error") {
|
|
this.sendBurstTextTurn(socket, frame.params, 1_700, "error");
|
|
return;
|
|
}
|
|
if (plan === "empty-terminal") {
|
|
this.sendEmptyTerminalTurn(socket, frame.params);
|
|
return;
|
|
}
|
|
this.sendTextTurn(socket, frame.params);
|
|
}
|
|
|
|
private sendBurstTextTurn(
|
|
socket: WebSocket,
|
|
identity: WorkerInferenceStartParams,
|
|
chunkCount = 1_100,
|
|
terminal: "done" | "error" = "done",
|
|
): void {
|
|
const chunk = "x".repeat(40);
|
|
this.send(socket, {
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 1,
|
|
event: {
|
|
type: "start",
|
|
resolvedModel: { api: "openai-responses", ...MODEL_REF },
|
|
timestamp: Date.now(),
|
|
},
|
|
},
|
|
} satisfies WorkerInferenceEventFrame);
|
|
this.send(socket, {
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 2,
|
|
event: { type: "text_start", contentIndex: 0 },
|
|
},
|
|
} satisfies WorkerInferenceEventFrame);
|
|
for (let index = 0; index < chunkCount; index += 1) {
|
|
this.send(socket, {
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: index + 3,
|
|
event: { type: "text_delta", contentIndex: 0, delta: chunk },
|
|
},
|
|
} satisfies WorkerInferenceEventFrame);
|
|
}
|
|
const text = chunk.repeat(chunkCount);
|
|
if (terminal === "error") {
|
|
this.sendTerminalOutcome(socket, identity, chunkCount + 3, {
|
|
type: "error",
|
|
reason: "provider-error",
|
|
message: "fixture provider failed after streaming",
|
|
});
|
|
} else {
|
|
this.sendTerminal(
|
|
socket,
|
|
identity,
|
|
chunkCount + 3,
|
|
assistantMessage([{ type: "text", text }], "stop"),
|
|
);
|
|
}
|
|
}
|
|
|
|
private sendEmptyTerminalTurn(socket: WebSocket, identity: WorkerInferenceStartParams): void {
|
|
this.send(socket, {
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 1,
|
|
event: {
|
|
type: "start",
|
|
resolvedModel: { api: "openai-responses", ...MODEL_REF },
|
|
timestamp: Date.now(),
|
|
},
|
|
},
|
|
} satisfies WorkerInferenceEventFrame);
|
|
this.send(socket, {
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 2,
|
|
event: { type: "text_start", contentIndex: 0 },
|
|
},
|
|
} satisfies WorkerInferenceEventFrame);
|
|
this.send(socket, {
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 3,
|
|
event: { type: "text_delta", contentIndex: 0, delta: "discarded draft" },
|
|
},
|
|
} satisfies WorkerInferenceEventFrame);
|
|
this.sendTerminal(socket, identity, 4, assistantMessage([], "stop"));
|
|
}
|
|
|
|
private sendTextTurn(socket: WebSocket, identity: WorkerInferenceStartParams): void {
|
|
const events: WorkerInferenceEventFrame[] = [
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 1,
|
|
event: {
|
|
type: "start",
|
|
resolvedModel: { api: "openai-responses", ...MODEL_REF },
|
|
timestamp: Date.now(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 2,
|
|
event: { type: "text_start", contentIndex: 0 },
|
|
},
|
|
},
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 3,
|
|
event: { type: "text_delta", contentIndex: 0, delta: "worker reply" },
|
|
},
|
|
},
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 4,
|
|
event: { type: "text_end", contentIndex: 0 },
|
|
},
|
|
},
|
|
];
|
|
for (const event of events) {
|
|
this.send(socket, event);
|
|
}
|
|
this.sendTerminal(
|
|
socket,
|
|
identity,
|
|
5,
|
|
assistantMessage([{ type: "text", text: "worker reply" }], "stop"),
|
|
);
|
|
}
|
|
|
|
private sendToolTurn(
|
|
socket: WebSocket,
|
|
identity: WorkerInferenceStartParams,
|
|
background = false,
|
|
): void {
|
|
const toolCallId = "local-exec-call";
|
|
const args = background
|
|
? {
|
|
// POSIX sleep avoids Node startup; Windows keeps the portable Node fixture.
|
|
command:
|
|
process.platform === "win32"
|
|
? `${JSON.stringify(process.execPath)} -e ${JSON.stringify(
|
|
"setInterval(() => undefined, 1000)",
|
|
)}`
|
|
: "exec sleep 60",
|
|
background: true,
|
|
}
|
|
: { command: "printf worker-local > local-proof.txt" };
|
|
const encodedArgs = JSON.stringify(args);
|
|
const events: WorkerInferenceEventFrame[] = [
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 1,
|
|
event: {
|
|
type: "start",
|
|
resolvedModel: { api: "openai-responses", ...MODEL_REF },
|
|
timestamp: Date.now(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 2,
|
|
event: { type: "toolcall_start", contentIndex: 0, id: toolCallId, toolName: "exec" },
|
|
},
|
|
},
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 3,
|
|
event: { type: "toolcall_delta", contentIndex: 0, delta: encodedArgs },
|
|
},
|
|
},
|
|
{
|
|
type: "event",
|
|
event: "worker.inference.event",
|
|
payload: {
|
|
...this.identity(identity),
|
|
seq: 4,
|
|
event: { type: "toolcall_end", contentIndex: 0 },
|
|
},
|
|
},
|
|
];
|
|
for (const event of events) {
|
|
this.send(socket, event);
|
|
}
|
|
this.sendTerminal(
|
|
socket,
|
|
identity,
|
|
5,
|
|
assistantMessage(
|
|
[{ type: "toolCall", id: toolCallId, name: "exec", arguments: args }],
|
|
"toolUse",
|
|
),
|
|
);
|
|
}
|
|
|
|
private sendTerminal(
|
|
socket: WebSocket,
|
|
identity: WorkerInferenceStartParams,
|
|
seq: number,
|
|
message: WorkerDoneMessage,
|
|
): void {
|
|
this.sendTerminalOutcome(socket, identity, seq, { type: "done", message });
|
|
}
|
|
|
|
private sendTerminalOutcome(
|
|
socket: WebSocket,
|
|
identity: WorkerInferenceStartParams,
|
|
seq: number,
|
|
outcome: WorkerInferenceTerminalOutcome,
|
|
): void {
|
|
const frame: WorkerInferenceTerminalFrame = {
|
|
type: "event",
|
|
event: "worker.inference.terminal",
|
|
payload: { ...this.identity(identity), seq, outcome },
|
|
};
|
|
this.send(socket, frame);
|
|
}
|
|
|
|
private identity(params: WorkerInferenceStartParams) {
|
|
return {
|
|
runEpoch: params.runEpoch,
|
|
sessionId: params.sessionId,
|
|
runId: params.runId,
|
|
turnId: params.turnId,
|
|
};
|
|
}
|
|
|
|
private send(socket: WebSocket, frame: object): void {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify(frame));
|
|
}
|
|
}
|
|
}
|
|
|
|
function descriptor(socketPath: string, workspaceDir: string): WorkerLaunchDescriptor {
|
|
return {
|
|
version: 1,
|
|
socketPath,
|
|
admission: {
|
|
environmentId: "worker-environment",
|
|
credential: CREDENTIAL,
|
|
sessionId: SESSION_ID,
|
|
ownerEpoch: OWNER_EPOCH,
|
|
rpcSetVersion: WORKER_RPC_SET_VERSION,
|
|
handshake: {
|
|
bundleHash: BUNDLE_HASH,
|
|
openclawVersion: "worker-test",
|
|
protocolFeatures: [...WORKER_PROTOCOL_FEATURES],
|
|
},
|
|
},
|
|
assignment: {
|
|
runId: RUN_ID,
|
|
turnId: "worker-turn",
|
|
prompt: "Complete the worker turn.",
|
|
suppressPromptTranscript: false,
|
|
workspaceDir,
|
|
modelRef: MODEL_REF,
|
|
inferenceOptions: { reasoning: "off" },
|
|
initialMessages: [],
|
|
transcript: { baseLeafId: "leaf-base", nextSeq: 3 },
|
|
liveEvents: { ackedSeq: 0, nextSeq: 1 },
|
|
},
|
|
};
|
|
}
|
|
|
|
const gateways: FakeWorkerGateway[] = [];
|
|
const tempDirs: string[] = [];
|
|
|
|
async function setup(options?: FakeGatewayOptions): Promise<{
|
|
gateway: FakeWorkerGateway;
|
|
workspaceDir: string;
|
|
launch: WorkerLaunchDescriptor;
|
|
}> {
|
|
const gateway = new FakeWorkerGateway(options);
|
|
gateways.push(gateway);
|
|
await gateway.start();
|
|
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-worker-workspace-"));
|
|
tempDirs.push(workspaceDir);
|
|
return { gateway, workspaceDir, launch: descriptor(gateway.socketPath, workspaceDir) };
|
|
}
|
|
|
|
afterEach(async () => {
|
|
for (const gateway of gateways.splice(0)) {
|
|
await gateway.stop();
|
|
}
|
|
for (const tempDir of tempDirs.splice(0)) {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("worker runtime", () => {
|
|
it("runs a full embedded turn through remote inference, live events, and transcript commits", async () => {
|
|
const { gateway, workspaceDir, launch } = await setup();
|
|
await writeFile(path.join(workspaceDir, "AGENTS.md"), "worker-bootstrap-marker", "utf8");
|
|
|
|
const result = await runWorkerDescriptor(launch);
|
|
|
|
expect(result.status).toBe("completed");
|
|
expect(gateway.inferenceRequests).toHaveLength(1);
|
|
expect(gateway.inferenceRequests[0]?.modelRef).toEqual(MODEL_REF);
|
|
expect(gateway.inferenceRequests[0]?.context.systemPrompt).toContain("worker-bootstrap-marker");
|
|
const toolNames = gateway.inferenceRequests[0]?.context.tools?.map((tool) => tool.name) ?? [];
|
|
expect(toolNames).toHaveLength(6);
|
|
const terminalIndex = gateway.applicationOrder.findIndex(
|
|
(entry) => entry === "live:lifecycle:end",
|
|
);
|
|
const finalTranscriptIndex = gateway.applicationOrder.findLastIndex((entry) =>
|
|
entry.startsWith("transcript:"),
|
|
);
|
|
expect(finalTranscriptIndex).toBeGreaterThanOrEqual(0);
|
|
expect(terminalIndex).toBeGreaterThan(finalTranscriptIndex);
|
|
expect(toolNames).toEqual(
|
|
expect.arrayContaining(["read", "write", "edit", "apply_patch", "exec", "process"]),
|
|
);
|
|
expect(gateway.liveEventRequests.some((request) => request.event.kind === "assistant")).toBe(
|
|
true,
|
|
);
|
|
const lifecycleEvents = gateway.liveEventRequests.flatMap((request) =>
|
|
request.event.kind === "lifecycle" ? [request.event.payload.phase] : [],
|
|
);
|
|
expect(lifecycleEvents).toContain("start");
|
|
expect(lifecycleEvents).toContain("end");
|
|
expect(gateway.transcriptRequests.length).toBeGreaterThan(0);
|
|
expect(gateway.transcriptRequests.map((request) => request.seq)).toEqual(
|
|
gateway.transcriptRequests.map((_request, index) => index + 3),
|
|
);
|
|
expect(
|
|
gateway.transcriptRequests
|
|
.flatMap((request) => request.messages)
|
|
.map((message) => message.role),
|
|
).toEqual(["user", "assistant"]);
|
|
const lastTranscript = gateway.transcriptRequests.at(-1);
|
|
expect(result).toMatchObject({
|
|
transcriptLeafId: `leaf-${lastTranscript?.seq}`,
|
|
transcriptNextSeq: (lastTranscript?.seq ?? 0) + 1,
|
|
});
|
|
});
|
|
|
|
it("fail-stops a stale mid-run transcript without duplicating or rebasing the paid tail", async () => {
|
|
const { gateway, launch } = await setup({ transcriptFailureAtRequest: 2 });
|
|
|
|
const failure: unknown = await runWorkerDescriptor(launch).then(
|
|
() => undefined,
|
|
(error: unknown) => error,
|
|
);
|
|
|
|
expect(failure).toBeInstanceOf(Error);
|
|
expect(failure).toMatchObject({
|
|
name: "WorkerTranscriptCommitError",
|
|
message:
|
|
"Worker transcript base changed; uncommitted messages were not committed; relaunch required.",
|
|
reason: "stale-base-leaf",
|
|
});
|
|
expect(gateway.inferenceRequests).toHaveLength(1);
|
|
expect(gateway.transcriptRequests.map((request) => request.seq)).toEqual([3, 4]);
|
|
expect(
|
|
gateway.transcriptRequests.map((request) => request.messages.map((message) => message.role)),
|
|
).toEqual([["user"], ["assistant"]]);
|
|
expect(gateway.acceptedTranscriptRequests.map((request) => request.seq)).toEqual([3]);
|
|
expect(
|
|
gateway.liveEventRequests.some(
|
|
(request) => request.event.kind === "lifecycle" && request.event.payload.phase === "error",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("renumbers live events after a gateway cursor reset without aborting the run", async () => {
|
|
const { gateway, launch } = await setup({ liveResyncAckedSeq: 0 });
|
|
launch.assignment.liveEvents = { ackedSeq: 5, nextSeq: 6 };
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
|
|
|
expect(gateway.inferenceRequests).toHaveLength(1);
|
|
expect(gateway.acceptedTranscriptRequests).toHaveLength(2);
|
|
expect(gateway.liveEventRequests.slice(0, 2)).toEqual([
|
|
expect.objectContaining({ seq: 6, lastAckedSeq: 5 }),
|
|
expect.objectContaining({ seq: 1, lastAckedSeq: 0 }),
|
|
]);
|
|
expect(gateway.liveEventRequests[1]?.event).toEqual(gateway.liveEventRequests[0]?.event);
|
|
});
|
|
|
|
it("requires authoritative terminal delivery after degrading preview live events", async () => {
|
|
const { gateway, launch } = await setup({ liveFailure: "capacity-exceeded" });
|
|
|
|
await expect(runWorkerDescriptor(launch)).rejects.toThrow("worker live event rejected");
|
|
|
|
expect(gateway.inferenceRequests).toHaveLength(1);
|
|
expect(
|
|
gateway.acceptedTranscriptRequests
|
|
.flatMap((request) => request.messages)
|
|
.map((message) => message.role),
|
|
).toEqual(["user", "assistant"]);
|
|
expect(gateway.liveEventRequests.length).toBeGreaterThanOrEqual(2);
|
|
expect(gateway.liveEventRequests.at(-1)?.event).toMatchObject({
|
|
kind: "lifecycle",
|
|
payload: { phase: "end" },
|
|
});
|
|
});
|
|
|
|
it("degrades a repeated no-progress live resync without hanging the run", async () => {
|
|
const { gateway, launch } = await setup({
|
|
liveResyncAckedSeq: 0,
|
|
liveResyncResponses: 2,
|
|
});
|
|
launch.assignment.liveEvents = { ackedSeq: 5, nextSeq: 6 };
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
|
|
|
expect(gateway.inferenceRequests).toHaveLength(1);
|
|
expect(gateway.acceptedTranscriptRequests).toHaveLength(2);
|
|
expect(gateway.liveEventRequests).toHaveLength(3);
|
|
expect(gateway.liveEventRequests.at(-1)?.event).toMatchObject({
|
|
kind: "lifecycle",
|
|
payload: { phase: "end" },
|
|
});
|
|
});
|
|
|
|
it("fails closed when worker admission is rejected", async () => {
|
|
const { gateway, launch } = await setup({ admissionFailure: "invalid-credential" });
|
|
|
|
await expect(runWorkerDescriptor(launch)).rejects.toThrow("worker admission rejected");
|
|
expect(gateway.connectionCount).toBe(1);
|
|
});
|
|
|
|
it("exits cleanly when admission observes a superseded owner epoch", async () => {
|
|
const { launch } = await setup({ admissionFailure: "owner-epoch-mismatch" });
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toEqual({
|
|
status: "fenced",
|
|
reason: "owner-epoch-mismatch",
|
|
});
|
|
});
|
|
|
|
it("exits cleanly when the owner epoch supersedes the worker", async () => {
|
|
const { launch } = await setup({ inferencePlans: ["fence"] });
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toEqual({
|
|
status: "fenced",
|
|
reason: "owner-epoch-mismatch",
|
|
});
|
|
});
|
|
|
|
it("sends remote inference cancellation before stopping an active worker", async () => {
|
|
const { gateway, launch } = await setup({ inferencePlans: ["hold"] });
|
|
const controller = new AbortController();
|
|
const result = runWorkerDescriptor(launch, { signal: controller.signal });
|
|
await vi.waitFor(() => expect(gateway.inferenceRequests).toHaveLength(1));
|
|
|
|
controller.abort(new Error("operator stopped worker"));
|
|
|
|
await expect(result).rejects.toThrow("operator stopped worker");
|
|
expect(gateway.methods).toContain("worker.inference.cancel");
|
|
expect(gateway.liveEventRequests.at(-1)?.event).toMatchObject({
|
|
kind: "lifecycle",
|
|
payload: { phase: "end", aborted: true },
|
|
});
|
|
});
|
|
|
|
it("bounds shutdown when remote inference cancellation cannot settle", async () => {
|
|
const { gateway, launch } = await setup({
|
|
inferencePlans: ["hold"],
|
|
outageOnInferenceCancel: true,
|
|
});
|
|
const controller = new AbortController();
|
|
const result = runWorkerDescriptor(launch, { signal: controller.signal });
|
|
await vi.waitFor(() => expect(gateway.inferenceRequests).toHaveLength(1));
|
|
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
try {
|
|
const rejected = expect(result).rejects.toThrow("operator stopped worker during outage");
|
|
|
|
controller.abort(new Error("operator stopped worker during outage"));
|
|
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1_000);
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
await rejected;
|
|
expect(gateway.methods).toContain("worker.inference.cancel");
|
|
} finally {
|
|
timeoutSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["error", "error", "error"],
|
|
["cancelled", "aborted", "end"],
|
|
] as const)(
|
|
"reports remote inference %s terminals as failed turns",
|
|
async (plan, stopReason, lifecyclePhase) => {
|
|
const { gateway, launch } = await setup({ inferencePlans: [plan] });
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "turn-failed",
|
|
});
|
|
const assistant = gateway.transcriptRequests
|
|
.flatMap((request) => request.messages)
|
|
.toReversed()
|
|
.find((entry) => entry.role === "assistant");
|
|
expect(assistant).toMatchObject({ stopReason });
|
|
const lifecycle = gateway.liveEventRequests
|
|
.map((request) => request.event)
|
|
.toReversed()
|
|
.find((event) => event.kind === "lifecycle");
|
|
expect(lifecycle).toMatchObject({ payload: { phase: lifecyclePhase } });
|
|
},
|
|
);
|
|
|
|
it("keeps an unacknowledged failed-turn terminal as an infrastructure failure", async () => {
|
|
const { gateway, launch } = await setup({
|
|
inferencePlans: ["error"],
|
|
liveFailure: "capacity-exceeded",
|
|
});
|
|
|
|
await expect(runWorkerDescriptor(launch)).rejects.toThrow("worker live event rejected");
|
|
expect(gateway.liveEventRequests.at(-1)?.event).toMatchObject({
|
|
kind: "lifecycle",
|
|
payload: { phase: "error" },
|
|
});
|
|
});
|
|
|
|
it("fails closed when a heartbeat is rejected without fencing", async () => {
|
|
const { launch } = await setup({
|
|
inferencePlans: ["hold"],
|
|
heartbeatFailure: "credential-expired",
|
|
heartbeatIntervalMs: 1,
|
|
});
|
|
|
|
await expect(runWorkerDescriptor(launch)).rejects.toThrow(
|
|
"worker heartbeat rejected: credential-expired",
|
|
);
|
|
});
|
|
|
|
it("coalesces bursty live output and keeps every frame below the byte ceiling", async () => {
|
|
const { gateway, launch } = await setup({ inferencePlans: ["burst-text"] });
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
|
|
|
const assistantEvents = gateway.liveEventRequests.filter(
|
|
(request) => request.event.kind === "assistant",
|
|
);
|
|
expect(assistantEvents.length).toBeGreaterThan(0);
|
|
expect(assistantEvents.length).toBeLessThan(1_100);
|
|
for (const request of gateway.liveEventRequests) {
|
|
expect(Buffer.byteLength(JSON.stringify(request), "utf8")).toBeLessThan(64 * 1024);
|
|
}
|
|
});
|
|
|
|
it.each(["oversized-text", "oversized-error"] as const)(
|
|
"turns %s output into a persistable failed turn",
|
|
async (plan) => {
|
|
const { gateway, launch } = await setup({ inferencePlans: [plan] });
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toEqual({
|
|
status: "failed",
|
|
reason: "turn-failed",
|
|
});
|
|
const assistant = gateway.transcriptRequests
|
|
.flatMap((request) => request.messages)
|
|
.toReversed()
|
|
.find((message) => message.role === "assistant");
|
|
expect(assistant).toMatchObject({ role: "assistant", stopReason: "error", content: [] });
|
|
for (const request of gateway.transcriptRequests) {
|
|
expect(Buffer.byteLength(JSON.stringify(request), "utf8")).toBeLessThan(64 * 1024);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("clears streamed text when the authoritative terminal message is empty", async () => {
|
|
const { gateway, launch } = await setup({ inferencePlans: ["empty-terminal"] });
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
|
|
|
const finalAssistant = gateway.liveEventRequests
|
|
.map((request) => request.event)
|
|
.toReversed()
|
|
.find((event) => event.kind === "assistant");
|
|
expect(finalAssistant).toEqual({
|
|
kind: "assistant",
|
|
payload: { text: "", delta: "", replace: true },
|
|
});
|
|
});
|
|
|
|
it("stops worker-scoped background processes when fenced", async () => {
|
|
const { gateway, launch } = await setup({
|
|
inferencePlans: ["background-tool", "fence"],
|
|
});
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toEqual({
|
|
status: "fenced",
|
|
reason: "owner-epoch-mismatch",
|
|
});
|
|
expect(gateway.inferenceRequests).toHaveLength(2);
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(
|
|
listRunningSessions().filter((session) => session.scopeKey === `worker:${SESSION_ID}`),
|
|
).toHaveLength(0);
|
|
},
|
|
{ timeout: 7_000 },
|
|
);
|
|
});
|
|
|
|
it("executes coding tools locally without reading the preexisting auth profile", async () => {
|
|
const { gateway, workspaceDir, launch } = await setup({ inferencePlans: ["tool", "text"] });
|
|
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
|
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
const trapStateDir = path.join(workspaceDir, "state-trap");
|
|
const authDir = path.join(trapStateDir, "agents", "main", "agent");
|
|
const configTrap = path.join(workspaceDir, "config-trap");
|
|
await mkdir(authDir, { recursive: true });
|
|
await writeFile(path.join(authDir, "auth-profiles.json"), "not valid json", "utf8");
|
|
await mkdir(configTrap);
|
|
process.env.OPENCLAW_STATE_DIR = trapStateDir;
|
|
process.env.OPENCLAW_CONFIG_PATH = configTrap;
|
|
try {
|
|
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
|
} finally {
|
|
if (previousStateDir === undefined) {
|
|
delete process.env.OPENCLAW_STATE_DIR;
|
|
} else {
|
|
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
|
}
|
|
if (previousConfigPath === undefined) {
|
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
} else {
|
|
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
|
|
}
|
|
}
|
|
|
|
await expect(readFile(path.join(workspaceDir, "local-proof.txt"), "utf8")).resolves.toBe(
|
|
"worker-local",
|
|
);
|
|
expect(gateway.inferenceRequests).toHaveLength(2);
|
|
expect(
|
|
gateway.inferenceRequests[1]?.context.messages.some(
|
|
(message) => message.role === "toolResult",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
gateway.methods.every((method) => method.startsWith("worker.") || method === "connect"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("windows near-limit history for every local tool-loop inference", async () => {
|
|
const { gateway, launch } = await setup({ inferencePlans: ["tool", "text"] });
|
|
launch.assignment.initialMessages = Array.from(
|
|
{ length: WORKER_INFERENCE_MAX_CONTEXT_MESSAGES },
|
|
(_value, index): WorkerTranscriptMessage => ({
|
|
role: "user",
|
|
content: [{ type: "text", text: `history-${index}` }],
|
|
timestamp: index + 1,
|
|
}),
|
|
);
|
|
|
|
await expect(runWorkerDescriptor(launch)).resolves.toMatchObject({ status: "completed" });
|
|
|
|
expect(gateway.inferenceRequests).toHaveLength(2);
|
|
for (const request of gateway.inferenceRequests) {
|
|
expect(request.context.messages.length).toBeLessThanOrEqual(
|
|
WORKER_INFERENCE_MAX_CONTEXT_MESSAGES,
|
|
);
|
|
expect(request.context.messages[0]?.role).toBe("user");
|
|
}
|
|
expect(
|
|
gateway.inferenceRequests[1]?.context.messages.some(
|
|
(message) => message.role === "toolResult",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
gateway.inferenceRequests[1]?.context.messages.slice(-3).map((message) => message.role),
|
|
).toEqual(["user", "assistant", "toolResult"]);
|
|
expect(
|
|
gateway.transcriptRequests
|
|
.flatMap((request) => request.messages)
|
|
.map((message) => message.role),
|
|
).toEqual(["user", "assistant", "toolResult", "assistant"]);
|
|
});
|
|
});
|
|
|
|
describe("worker reconnect clients", () => {
|
|
it("fails closed when the overall admission deadline expires", async () => {
|
|
const { gateway, launch } = await setup({ admissionFailure: "gateway-unavailable" });
|
|
const connection = createWorkerConnection({
|
|
socketPath: gateway.socketPath,
|
|
connectParams: buildWorkerConnectParams(launch),
|
|
admissionTimeoutMs: 25,
|
|
admissionDeadlineMs: 250,
|
|
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
|
|
});
|
|
try {
|
|
await expect(connection.start()).rejects.toBeInstanceOf(WorkerAdmissionDeadlineExceededError);
|
|
expect(gateway.connectionCount).toBeGreaterThan(1);
|
|
expect(connection.state).toMatchObject({
|
|
kind: "failed",
|
|
error: expect.any(WorkerAdmissionDeadlineExceededError),
|
|
});
|
|
await expect(connection.waitForExit()).resolves.toMatchObject({
|
|
kind: "failed",
|
|
error: expect.any(WorkerAdmissionDeadlineExceededError),
|
|
});
|
|
} finally {
|
|
await connection.stop();
|
|
}
|
|
});
|
|
|
|
it("times out a silent admission attempt and admits on reconnect", async () => {
|
|
const { gateway, launch } = await setup({ ignoreFirstAdmission: true });
|
|
const connection = createWorkerConnection({
|
|
socketPath: gateway.socketPath,
|
|
connectParams: buildWorkerConnectParams(launch),
|
|
admissionTimeoutMs: 25,
|
|
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
|
|
});
|
|
try {
|
|
await expect(connection.start()).resolves.toMatchObject({ ownerEpoch: OWNER_EPOCH });
|
|
expect(gateway.connectionCount).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
await connection.stop();
|
|
}
|
|
});
|
|
|
|
it("times out a silent heartbeat and reconnects", async () => {
|
|
const { gateway, launch } = await setup({
|
|
ignoreHeartbeat: true,
|
|
heartbeatIntervalMs: 1,
|
|
});
|
|
const connection = createWorkerConnection({
|
|
socketPath: gateway.socketPath,
|
|
connectParams: buildWorkerConnectParams(launch),
|
|
requestTimeoutMs: 25,
|
|
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
|
|
});
|
|
try {
|
|
await connection.start();
|
|
await vi.waitFor(() => expect(gateway.connectionCount).toBeGreaterThanOrEqual(2));
|
|
} finally {
|
|
await connection.stop();
|
|
}
|
|
});
|
|
|
|
it("replays exact RPC payloads after silent response timeouts", async () => {
|
|
const { gateway, launch } = await setup({
|
|
silenceFirstTranscript: true,
|
|
silenceFirstLiveEvent: true,
|
|
silenceFirstInference: true,
|
|
});
|
|
const connection = createWorkerConnection({
|
|
socketPath: gateway.socketPath,
|
|
connectParams: buildWorkerConnectParams(launch),
|
|
requestTimeoutMs: 40,
|
|
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
|
|
});
|
|
const transcript = new WorkerTranscriptCommitClient(connection, {
|
|
runEpoch: OWNER_EPOCH,
|
|
baseLeafId: "leaf-base",
|
|
initialSeq: 8,
|
|
});
|
|
const live = new WorkerLiveEventClient(connection, { runEpoch: OWNER_EPOCH });
|
|
const inference = new WorkerInferenceProxyClient(connection);
|
|
try {
|
|
await connection.start();
|
|
await transcript.commit([
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "silent transcript" }],
|
|
timestamp: 1,
|
|
},
|
|
]);
|
|
await live.emit(RUN_ID, {
|
|
kind: "assistant",
|
|
payload: { text: "silent live event", delta: "silent live event" },
|
|
});
|
|
await inference.start({
|
|
runEpoch: OWNER_EPOCH,
|
|
sessionId: SESSION_ID,
|
|
runId: RUN_ID,
|
|
turnId: "silent-inference",
|
|
modelRef: MODEL_REF,
|
|
context: { messages: [] },
|
|
options: {},
|
|
});
|
|
|
|
expect(gateway.transcriptRequests).toHaveLength(2);
|
|
expect(gateway.transcriptRequests[1]).toEqual(gateway.transcriptRequests[0]);
|
|
expect(gateway.liveEventRequests).toHaveLength(2);
|
|
expect(gateway.liveEventRequests[1]).toEqual(gateway.liveEventRequests[0]);
|
|
expect(gateway.inferenceRequests).toHaveLength(2);
|
|
expect(gateway.inferenceRequests[1]).toEqual(gateway.inferenceRequests[0]);
|
|
expect(gateway.connectionCount).toBeGreaterThanOrEqual(4);
|
|
} finally {
|
|
inference.dispose();
|
|
live.dispose();
|
|
await connection.stop();
|
|
}
|
|
});
|
|
|
|
it("settles an in-flight commit and a later live emit after stop", async () => {
|
|
const { gateway, launch } = await setup({ silenceFirstTranscript: true });
|
|
const connection = createWorkerConnection({
|
|
socketPath: gateway.socketPath,
|
|
connectParams: buildWorkerConnectParams(launch),
|
|
requestTimeoutMs: 5_000,
|
|
reconnectBackoff: { initialMs: 1, maxMs: 1, factor: 1, jitter: 0 },
|
|
});
|
|
const originalWaitForReady = connection.waitForReady.bind(connection);
|
|
const waitForReady = vi.spyOn(connection, "waitForReady").mockImplementation(() => {
|
|
if (waitForReady.mock.calls.length > 4) {
|
|
throw new Error("worker client retried after terminal stop");
|
|
}
|
|
return originalWaitForReady();
|
|
});
|
|
const transcript = new WorkerTranscriptCommitClient(connection, {
|
|
runEpoch: OWNER_EPOCH,
|
|
baseLeafId: "leaf-base",
|
|
initialSeq: 8,
|
|
});
|
|
let live: WorkerLiveEventClient | undefined;
|
|
try {
|
|
await connection.start();
|
|
const commit = transcript.commit([
|
|
{
|
|
role: "user",
|
|
content: [{ type: "text", text: "commit interrupted by stop" }],
|
|
timestamp: 1,
|
|
},
|
|
]);
|
|
await vi.waitFor(() => expect(gateway.transcriptRequests).toHaveLength(1));
|
|
|
|
await connection.stop();
|
|
await expect(commit).rejects.toBeInstanceOf(WorkerConnectionStoppedError);
|
|
|
|
live = new WorkerLiveEventClient(connection, { runEpoch: OWNER_EPOCH });
|
|
await expect(
|
|
live.emit(RUN_ID, {
|
|
kind: "assistant",
|
|
payload: { text: "late live event", delta: "late live event" },
|
|
}),
|
|
).rejects.toBeInstanceOf(WorkerConnectionStoppedError);
|
|
expect(waitForReady.mock.calls.length).toBeLessThanOrEqual(2);
|
|
expect(gateway.liveEventRequests).toHaveLength(0);
|
|
} finally {
|
|
live?.dispose();
|
|
await connection.stop();
|
|
}
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|