mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 02:11:16 +00:00
* 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.
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
import type { WorkerTranscriptMessage } from "../../packages/gateway-protocol/src/schema/worker-admission.js";
|
|
import { WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES } from "../../packages/gateway-protocol/src/schema/worker-admission.js";
|
|
import type { WorkerInferenceContext } from "../../packages/gateway-protocol/src/schema/worker-inference.js";
|
|
import { WORKER_INFERENCE_MAX_CONTEXT_MESSAGES } from "../../packages/gateway-protocol/src/schema/worker-inference.js";
|
|
import type { AgentMessage } from "../agents/runtime/index.js";
|
|
import type { AgentSessionWriteLockRunner } from "../agents/sessions/agent-session.js";
|
|
import type { Context, Message } from "../llm/types.js";
|
|
import {
|
|
cloneImageContent,
|
|
cloneTextContent,
|
|
cloneUsage,
|
|
isWorkerTranscriptMessageFrameSafe,
|
|
toWorkerTranscriptMessage,
|
|
} from "./transcript-message.js";
|
|
|
|
export function toAgentMessage(message: WorkerTranscriptMessage): Message {
|
|
if (message.role === "user") {
|
|
return {
|
|
role: "user",
|
|
content: message.content.map((part) =>
|
|
part.type === "text" ? cloneTextContent(part) : cloneImageContent(part),
|
|
),
|
|
timestamp: message.timestamp,
|
|
};
|
|
}
|
|
if (message.role === "toolResult") {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId: message.toolCallId,
|
|
toolName: message.toolName,
|
|
content: message.content.map((part) =>
|
|
part.type === "text" ? cloneTextContent(part) : cloneImageContent(part),
|
|
),
|
|
...(message.details === undefined ? {} : { details: structuredClone(message.details) }),
|
|
isError: message.isError,
|
|
timestamp: message.timestamp,
|
|
};
|
|
}
|
|
return {
|
|
...cloneUsage(message),
|
|
diagnostics: message.diagnostics?.map((diagnostic) => structuredClone(diagnostic)),
|
|
};
|
|
}
|
|
|
|
function toWorkerInferenceMessage(message: Message): WorkerInferenceContext["messages"][number] {
|
|
if (message.role === "user") {
|
|
return {
|
|
role: "user",
|
|
content:
|
|
typeof message.content === "string"
|
|
? message.content
|
|
: message.content.map((part) =>
|
|
part.type === "text" ? cloneTextContent(part) : cloneImageContent(part),
|
|
),
|
|
timestamp: message.timestamp,
|
|
...(message.runtimeContextCarrier ? { runtimeContextCarrier: true } : {}),
|
|
};
|
|
}
|
|
const projected = toWorkerTranscriptMessage(message);
|
|
if (!projected) {
|
|
throw new Error(`Unsupported inference message role: ${message.role}`);
|
|
}
|
|
return projected;
|
|
}
|
|
|
|
function windowWorkerInferenceMessages(messages: Context["messages"]): Context["messages"] {
|
|
if (messages.length <= WORKER_INFERENCE_MAX_CONTEXT_MESSAGES) {
|
|
return messages;
|
|
}
|
|
const minimumStart = messages.length - WORKER_INFERENCE_MAX_CONTEXT_MESSAGES;
|
|
// Start at a user turn when possible so truncation cannot orphan a tool result
|
|
// from the assistant tool call that owns it.
|
|
for (let index = minimumStart; index < messages.length; index += 1) {
|
|
if (messages[index]?.role === "user") {
|
|
return messages.slice(index);
|
|
}
|
|
}
|
|
throw new Error("Worker inference context has no complete user turn within the message limit.");
|
|
}
|
|
|
|
export function toWorkerInferenceContext(context: Context): WorkerInferenceContext {
|
|
return {
|
|
...(context.systemPrompt === undefined ? {} : { systemPrompt: context.systemPrompt }),
|
|
messages: windowWorkerInferenceMessages(context.messages).map(toWorkerInferenceMessage),
|
|
...(context.tools
|
|
? {
|
|
tools: context.tools.map((tool) => ({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: structuredClone(tool.parameters),
|
|
})),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
type WorkerTranscriptClient = {
|
|
commit: (messages: WorkerTranscriptMessage[]) => Promise<void>;
|
|
};
|
|
|
|
type WorkerTranscriptRuntime = {
|
|
onMessagePersisted: (message: AgentMessage) => void;
|
|
withSessionWriteLock: AgentSessionWriteLockRunner;
|
|
};
|
|
|
|
export function createWorkerTranscriptRuntime(
|
|
client: WorkerTranscriptClient,
|
|
): WorkerTranscriptRuntime {
|
|
const pendingTranscriptMessages: WorkerTranscriptMessage[] = [];
|
|
const onMessagePersisted = (message: AgentMessage) => {
|
|
const projected = toWorkerTranscriptMessage(message);
|
|
if (projected) {
|
|
if (!isWorkerTranscriptMessageFrameSafe(projected)) {
|
|
throw new Error("Worker transcript message exceeds the protocol payload limit.");
|
|
}
|
|
pendingTranscriptMessages.push(projected);
|
|
}
|
|
};
|
|
const flushTranscript = async () => {
|
|
while (pendingTranscriptMessages.length > 0) {
|
|
const batch = pendingTranscriptMessages.slice(0, WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES);
|
|
await client.commit(batch);
|
|
pendingTranscriptMessages.splice(0, batch.length);
|
|
}
|
|
};
|
|
let sessionWriteQueue: Promise<unknown> = Promise.resolve();
|
|
const withSessionWriteLock: AgentSessionWriteLockRunner = <T>(
|
|
operation: () => Promise<T> | T,
|
|
): Promise<T> => {
|
|
const result = sessionWriteQueue.then(async () => {
|
|
const value = await operation();
|
|
await flushTranscript();
|
|
return value;
|
|
});
|
|
sessionWriteQueue = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return result;
|
|
};
|
|
return { onMessagePersisted, withSessionWriteLock };
|
|
}
|