Files
openclaw/src/worker/worker.runtime.ts
Peter Steinberger 6cd1133858 feat(cloud-workers): openclaw worker mode running the embedded loop on the box (#105865)
* feat(cloud-workers): add restricted worker runtime

* test(cloud-workers): cover worker runtime contracts

* docs(cloud-workers): document worker mode

* fix(worker): bound connection lifecycle

* fix(worker): serialize transcript flushes

* fix(worker): fail stop stale transcript bases

* fix(worker): degrade lossy live streaming

* test(worker): cover inference reconnect fencing

* refactor(worker): split RPC clients

* refactor(worker): split connection transport

* refactor(worker): split embedded runtime

* refactor(worker): narrow worker module exports
2026-07-13 02:34:31 -07:00

187 lines
6.3 KiB
TypeScript

import { chmod, mkdtemp, realpath, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildWorkerConnectParams, type WorkerLaunchDescriptor } from "./launch-descriptor.js";
import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js";
import {
WorkerInferenceProxyClient,
WorkerLiveEventClient,
WorkerTranscriptCommitClient,
} from "./worker-rpc-clients.js";
type WorkerRuntimeResult =
| { status: "completed"; transcriptLeafId: string | null; transcriptNextSeq: number }
| { status: "fenced"; reason: "credential-replaced" | "owner-epoch-mismatch" };
const WORKER_REMOTE_CANCEL_GRACE_MS = 1_000;
function toError(value: unknown, fallback: string): Error {
return value instanceof Error ? value : new Error(fallback, { cause: value });
}
function fencedResult(state: WorkerConnectionState): WorkerRuntimeResult | undefined {
if (
state.kind === "fenced" &&
(state.reason === "credential-replaced" || state.reason === "owner-epoch-mismatch")
) {
return { status: "fenced", reason: state.reason };
}
return undefined;
}
async function assertWorkspaceDirectory(workspaceDir: string): Promise<string> {
const resolved = await realpath(workspaceDir);
const workspaceStat = await stat(resolved);
if (!workspaceStat.isDirectory()) {
throw new Error("worker workspace path must be a directory");
}
return resolved;
}
export async function runWorkerDescriptor(
descriptor: WorkerLaunchDescriptor,
options: { signal?: AbortSignal } = {},
): Promise<WorkerRuntimeResult> {
const workspaceDir = await assertWorkspaceDirectory(descriptor.assignment.workspaceDir);
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-worker-"));
await chmod(stateDir, 0o700);
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json");
const abortController = new AbortController();
let turnStarted = false;
let forcedStopTimer: NodeJS.Timeout | undefined;
const connection = createWorkerConnection({
socketPath: descriptor.socketPath,
connectParams: buildWorkerConnectParams(descriptor),
});
const abortFromCaller = () => {
abortController.abort(options.signal?.reason);
if (!turnStarted) {
void connection.stop();
return;
}
forcedStopTimer = setTimeout(() => {
void connection.stop();
}, WORKER_REMOTE_CANCEL_GRACE_MS);
forcedStopTimer.unref();
};
options.signal?.addEventListener("abort", abortFromCaller, { once: true });
if (options.signal?.aborted) {
abortFromCaller();
}
const transcript = new WorkerTranscriptCommitClient(connection, {
runEpoch: descriptor.admission.ownerEpoch,
baseLeafId: descriptor.assignment.transcript.baseLeafId,
initialSeq: descriptor.assignment.transcript.nextSeq,
});
const live = new WorkerLiveEventClient(connection, {
runEpoch: descriptor.admission.ownerEpoch,
initialAckedSeq: descriptor.assignment.liveEvents.ackedSeq,
});
const inference = new WorkerInferenceProxyClient(connection);
const unsubscribeState = connection.onStateChange((state) => {
if (state.kind === "fenced") {
abortController.abort(new Error(`worker fenced: ${state.reason}`));
} else if (state.kind === "failed") {
abortController.abort(state.error);
}
});
try {
try {
await connection.start();
} catch (error) {
const fenced = fencedResult(connection.state);
if (fenced) {
return fenced;
}
throw error;
}
const [{ runWorkerEmbeddedTurn }, { createWorkerInferenceStreamAdapter }] = await Promise.all([
import("./embedded-agent.runtime.js"),
import("./inference-stream.runtime.js"),
]);
const stream = createWorkerInferenceStreamAdapter({
client: inference,
sessionId: descriptor.admission.sessionId,
runEpoch: descriptor.admission.ownerEpoch,
runId: descriptor.assignment.runId,
turnId: descriptor.assignment.turnId,
modelRef: descriptor.assignment.modelRef,
});
try {
turnStarted = true;
await runWorkerEmbeddedTurn({
cwd: workspaceDir,
stateDir,
sessionId: descriptor.admission.sessionId,
sessionKey: `worker:${descriptor.admission.sessionId}`,
runId: descriptor.assignment.runId,
prompt: descriptor.assignment.prompt,
modelRef: descriptor.assignment.modelRef,
initialMessages: descriptor.assignment.initialMessages,
...(descriptor.assignment.systemPrompt === undefined
? {}
: { systemPrompt: descriptor.assignment.systemPrompt }),
inferenceOptions: descriptor.assignment.inferenceOptions,
inference: { stream },
transcript: {
commit: async (messages) => {
await transcript.commit(messages);
},
},
live: {
emit: async (event) => {
await live.emit(descriptor.assignment.runId, event);
},
},
signal: abortController.signal,
});
if (options.signal?.aborted) {
throw toError(options.signal.reason, "worker interrupted");
}
} catch (error) {
const fenced = fencedResult(connection.state);
if (fenced) {
return fenced;
}
throw toError(error, "worker session failed");
}
const fenced = fencedResult(connection.state);
if (fenced) {
return fenced;
}
if (connection.state.kind === "failed") {
throw connection.state.error;
}
return {
status: "completed",
transcriptLeafId: transcript.baseLeafId,
transcriptNextSeq: transcript.nextSeq,
};
} finally {
if (forcedStopTimer) {
clearTimeout(forcedStopTimer);
}
unsubscribeState();
options.signal?.removeEventListener("abort", abortFromCaller);
inference.dispose();
live.dispose();
await connection.stop();
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 rm(stateDir, { recursive: true, force: true });
}
}