Files
openclaw/src/worker/embedded-agent-live.runtime.ts
Peter Steinberger e98c7dfbcb feat(cloud-workers): session placement, dispatch, and worker turn routing (#106332)
* feat(gateway-protocol): add session placement schema

Closed state discriminator for session execution placement (local/requested/provisioning/syncing/starting/active/draining/reconciling/reclaimed/failed), sessions.dispatch params, and worker-admission transcript/live cursor extensions. Swift protocol models mirror the schema.

* feat(state): add worker session placement table

worker_session_placements rows carry placement state, transition generation, worker ownership metadata, ACK cursors, and the turn claim columns used for atomic admission.

* feat(cloud-workers): add durable placement state machine store

SQLite-backed placement store split by concern: state table (placement-state), discriminated record types + shape invariants (placement-record), row codec + CAS transition values (placement-row-codec), atomic turn-claim admission/release/waiters (placement-turn-claims), and lifecycle CAS transitions (placement-store).

* feat(cloud-workers): sync workspaces and attach sessions to worker environments

Environment service session attachment + turn credentials, tunnel workspace commands over a dedicated SSH runner, and git/plain workspace sync into $HOME/.openclaw-worker/workspaces with an immutable manifest. Symlink escapes are rejected locally before transfer (macOS openrsync stat-fails them opaquely) and again by the remote manifest guard.

* feat(worker): run one-shot embedded turns from launch descriptors

Worker runtime executes a single embedded turn from a stdin launch descriptor and reports completed/failed/fenced on stdout for the gateway launcher. Terminal lifecycle live events are deferred past the final transcript flush; transcript projection helpers are shared via transcript-message instead of duplicated in the runtime.

* feat(cloud-workers): dispatch placements and route worker turns

Dispatch service drives local->requested->provisioning->syncing->starting->active with failure teardown (placement-dispatch-failure) and restart/runtime recovery incl. lost-worker reclaim (placement-dispatch-recovery). Worker turn launcher claims the placement turn atomically, builds a windowed launch descriptor (worker-turn-payload), runs the remote one-shot worker, and reconciles the committed transcript; agent runners route turns through the session placement admission provider.

* feat(gateway): expose session placement RPCs and startup reconciliation

sessions.dispatch RPC with lifecycle admission barriers, operator-facing placement projection on session listings, placement-aware session reset guard, and startup/interval reconciliation wiring for worker placements.
2026-07-13 08:03:53 -07:00

384 lines
11 KiB
TypeScript

import type { WorkerLiveEvent } from "../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { AgentMessage } from "../agents/runtime/index.js";
import type { AgentSessionEvent } from "../agents/sessions/agent-session.js";
import type { AssistantMessage } from "../llm/types.js";
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
const MAX_LIVE_EVENT_BYTES = 32 * 1024;
const MAX_LIVE_PREVIEW_BYTES = 4 * 1024;
function liveEventBytes(event: WorkerLiveEvent): number {
try {
return Buffer.byteLength(JSON.stringify(event), "utf8");
} catch {
return Number.POSITIVE_INFINITY;
}
}
function truncateLiveText(value: string): string {
if (Buffer.byteLength(value, "utf8") <= MAX_LIVE_PREVIEW_BYTES) {
return value;
}
const suffix = "…";
return `${truncateUtf8Prefix(
value,
MAX_LIVE_PREVIEW_BYTES - Buffer.byteLength(suffix, "utf8"),
)}${suffix}`;
}
function boundLiveValue(value: unknown): unknown {
try {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
return null;
}
if (Buffer.byteLength(serialized, "utf8") <= MAX_LIVE_PREVIEW_BYTES) {
return structuredClone(value);
}
return { truncated: true, preview: truncateLiveText(serialized) };
} catch {
return { truncated: true, preview: "[unserializable live payload]" };
}
}
function boundLiveEvent(event: WorkerLiveEvent): WorkerLiveEvent {
if (liveEventBytes(event) <= MAX_LIVE_EVENT_BYTES) {
return structuredClone(event);
}
let bounded: WorkerLiveEvent;
if (event.kind === "assistant") {
const text = truncateLiveText(event.payload.text);
bounded = {
kind: "assistant",
payload: {
...event.payload,
text,
delta: text,
replace: true,
},
};
} else if (event.kind === "thinking") {
bounded = {
kind: "thinking",
payload: {
text: truncateLiveText(event.payload.text),
delta: truncateLiveText(event.payload.delta),
},
};
} else if (event.kind === "tool") {
if (event.payload.phase === "start") {
bounded = {
kind: "tool",
payload: { ...event.payload, args: boundLiveValue(event.payload.args) },
};
} else if (event.payload.phase === "update") {
bounded = {
kind: "tool",
payload: {
...event.payload,
partialResult: boundLiveValue(event.payload.partialResult),
},
};
} else {
bounded = {
kind: "tool",
payload: { ...event.payload, result: boundLiveValue(event.payload.result) },
};
}
} else if (event.kind === "lifecycle" && event.payload.phase === "error") {
bounded = {
kind: "lifecycle",
payload: { ...event.payload, error: truncateLiveText(event.payload.error) },
};
} else {
throw new Error(`worker live ${event.kind} event exceeds the protocol payload limit`);
}
if (liveEventBytes(bounded) > MAX_LIVE_EVENT_BYTES) {
throw new Error(`worker live ${event.kind} event cannot fit the protocol payload limit`);
}
return bounded;
}
function coalescePendingLiveEvent(pending: WorkerLiveEvent[], event: WorkerLiveEvent): boolean {
const index = pending.length - 1;
const previous = pending[index];
if (!previous) {
return false;
}
if (previous.kind === "assistant" && event.kind === "assistant") {
pending[index] = boundLiveEvent({
kind: "assistant",
payload: { ...event.payload, delta: event.payload.text, replace: true },
});
return true;
}
if (previous.kind === "thinking" && event.kind === "thinking") {
if (event.payload.text === "" && event.payload.delta === "") {
return false;
}
pending[index] = boundLiveEvent({
kind: "thinking",
payload: {
text: event.payload.text,
delta: `${previous.payload.delta}${event.payload.delta}`,
},
});
return true;
}
if (
previous.kind === "tool" &&
previous.payload.phase === "update" &&
event.kind === "tool" &&
event.payload.phase === "update" &&
previous.payload.toolCallId === event.payload.toolCallId
) {
pending[index] = boundLiveEvent(event);
return true;
}
return false;
}
function readAssistantText(message: AgentMessage): string {
if (message.role !== "assistant") {
return "";
}
return message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
}
function readAssistantThinking(message: AgentMessage): string {
if (message.role !== "assistant") {
return "";
}
return message.content
.filter((part) => part.type === "thinking")
.map((part) => part.thinking)
.join("");
}
type WorkerLiveClient = {
emit: (event: WorkerLiveEvent) => Promise<void>;
};
type WorkerLiveRuntime = {
handleSessionEvent: (event: AgentSessionEvent) => void;
enqueueRunFailure: (failure: { aborted: boolean; error: Error }) => void;
flush: () => Promise<void>;
emitTerminal: () => Promise<void>;
};
export function createWorkerLiveRuntime(client: WorkerLiveClient): WorkerLiveRuntime {
const pendingLiveEvents: WorkerLiveEvent[] = [];
let liveDrain: Promise<void> | undefined;
let liveDegraded = false;
const startLiveDrain = () => {
if (liveDrain || liveDegraded || pendingLiveEvents.length === 0) {
return;
}
liveDrain = (async () => {
while (true) {
const event = pendingLiveEvents.shift();
if (!event) {
return;
}
await client.emit(event);
}
})()
.catch(() => {
// Live events are preview-only; transcript commits and inference stay authoritative.
liveDegraded = true;
pendingLiveEvents.length = 0;
})
.finally(() => {
liveDrain = undefined;
startLiveDrain();
});
};
const enqueueLive = (event: WorkerLiveEvent) => {
if (liveDegraded) {
return;
}
try {
const bounded = boundLiveEvent(event);
if (!coalescePendingLiveEvent(pendingLiveEvents, bounded)) {
pendingLiveEvents.push(bounded);
}
startLiveDrain();
} catch {
liveDegraded = true;
pendingLiveEvents.length = 0;
}
};
const flush = async () => {
let drain = liveDrain;
while (drain) {
await drain;
drain = liveDrain;
}
};
const startedAt = Date.now();
let lifecycleFinished = false;
// Terminal lifecycle events are deferred past the final transcript flush so the
// gateway never sees an end/error before the authoritative transcript commit.
let terminalLiveEvent: WorkerLiveEvent | undefined;
let streamedText = "";
let streamedThinking = "";
const handleSessionEvent = (event: AgentSessionEvent) => {
if (event.type === "agent_start") {
enqueueLive({ kind: "lifecycle", payload: { phase: "start", startedAt } });
return;
}
if (event.type === "message_start" && event.message.role === "assistant") {
streamedText = "";
streamedThinking = "";
return;
}
if (event.type === "message_update") {
if (event.assistantMessageEvent.type === "text_delta") {
streamedText = readAssistantText(event.message);
enqueueLive({
kind: "assistant",
payload: { text: streamedText, delta: event.assistantMessageEvent.delta },
});
} else if (event.assistantMessageEvent.type === "thinking_delta") {
streamedThinking = readAssistantThinking(event.message);
enqueueLive({
kind: "thinking",
payload: { text: streamedThinking, delta: event.assistantMessageEvent.delta },
});
}
return;
}
if (event.type === "message_end" && event.message.role === "assistant") {
const finalText = readAssistantText(event.message);
if (finalText !== streamedText) {
enqueueLive({
kind: "assistant",
payload: { text: finalText, delta: finalText, replace: true },
});
}
const finalThinking = readAssistantThinking(event.message);
if (finalThinking !== streamedThinking) {
enqueueLive({
kind: "thinking",
payload: { text: finalThinking, delta: finalThinking },
});
}
return;
}
if (event.type === "tool_execution_start") {
enqueueLive({
kind: "tool",
payload: {
phase: "start",
name: event.toolName,
toolCallId: event.toolCallId,
args: event.args,
...(event.hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
return;
}
if (event.type === "tool_execution_update") {
enqueueLive({
kind: "tool",
payload: {
phase: "update",
name: event.toolName,
toolCallId: event.toolCallId,
partialResult: event.partialResult,
...(event.hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
return;
}
if (event.type === "tool_execution_end") {
enqueueLive({
kind: "tool",
payload: {
phase: "result",
name: event.toolName,
toolCallId: event.toolCallId,
isError: event.isError,
result: event.result,
...(event.hideFromChannelProgress ? { hideFromChannelProgress: true } : {}),
},
});
return;
}
if (event.type === "agent_end") {
lifecycleFinished = true;
const lastAssistant = event.messages
.toReversed()
.find((message): message is AssistantMessage => message.role === "assistant");
if (lastAssistant?.stopReason === "error") {
terminalLiveEvent = {
kind: "lifecycle",
payload: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: lastAssistant.errorMessage ?? "Worker inference failed.",
fallbackExhaustedFailure: true,
},
};
} else if (lastAssistant?.stopReason === "aborted") {
terminalLiveEvent = {
kind: "lifecycle",
payload: {
phase: "end",
startedAt,
endedAt: Date.now(),
stopReason: "aborted",
aborted: true,
},
};
} else {
terminalLiveEvent = {
kind: "lifecycle",
payload: { phase: "end", startedAt, endedAt: Date.now() },
};
}
}
};
const enqueueRunFailure = (failure: { aborted: boolean; error: Error }) => {
if (lifecycleFinished) {
return;
}
if (failure.aborted) {
terminalLiveEvent = {
kind: "lifecycle",
payload: {
phase: "end",
startedAt,
endedAt: Date.now(),
stopReason: "aborted",
aborted: true,
},
};
} else {
terminalLiveEvent = {
kind: "lifecycle",
payload: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: failure.error.message,
fallbackExhaustedFailure: true,
},
};
}
};
// Emits directly (not via the degradable preview queue): the terminal event drives
// gateway turn settlement and must survive a degraded live stream.
const emitTerminal = async () => {
if (!terminalLiveEvent) {
return;
}
await client.emit(boundLiveEvent(terminalLiveEvent));
};
return { handleSessionEvent, enqueueRunFailure, flush, emitTerminal };
}