feat(cloud-workers): durable transcript commit protocol for worker sessions (#104809)

* feat(cloud-workers): add transcript commit protocol

* feat(cloud-workers): persist transcript commit replay state

* feat(cloud-workers): apply transcript commits to sessions

* fix(cloud-workers): harden transcript commit admission

* fix(cloud-workers): recover branched transcript commits

* fix(cloud-workers): preserve diagnostic semantics
This commit is contained in:
Peter Steinberger
2026-07-11 17:29:07 -07:00
committed by GitHub
parent 2e6c1090c1
commit 04f945fabe
25 changed files with 2908 additions and 28 deletions
+6 -2
View File
@@ -176,8 +176,12 @@ verifies a hash-at-rest, short-lived credential bound to the environment, bundle
hash, owner epoch, RPC-set version, expiry, and one nullable session; it
separately checks the current version and feature set. Success returns minimal
`worker-hello-ok`; feature negotiation is independent of the general protocol
version. Frames stay under 64 KiB. Initially only `worker.heartbeat` is allowed,
with ownership and expiry rechecked on each RPC.
version. Frames stay under 64 KiB. The closed allowlist contains
`worker.heartbeat` and `worker.transcript.commit` for ordered semantic message batches. Transcript
commits use owner-epoch fencing, a gateway-owned session binding, base-leaf
compare-and-swap, and durable sequence replay; the gateway generates transcript
entry and parent IDs through the normal session writer. Ownership and expiry are
rechecked on each RPC.
### Client capabilities
+95 -1
View File
@@ -421,6 +421,20 @@ import {
type WorkerHelloOk,
type WorkerProtocolCloseReason,
WorkerProtocolCloseReasonSchema,
type WorkerTranscriptCommitErrorReason,
WorkerTranscriptCommitErrorReasonSchema,
type WorkerTranscriptCommitErrorShape,
WorkerTranscriptCommitErrorShapeSchema,
type WorkerTranscriptCommitParams,
WorkerTranscriptCommitParamsSchema,
type WorkerTranscriptCommitRequestFrame,
WorkerTranscriptCommitRequestFrameSchema,
type WorkerTranscriptCommitResponseFrame,
WorkerTranscriptCommitResponseFrameSchema,
type WorkerTranscriptCommitResult,
WorkerTranscriptCommitResultSchema,
type WorkerTranscriptMessage,
WorkerTranscriptMessageSchema,
WORKER_HEARTBEAT_INTERVAL_MS,
WORKER_PROTOCOL_FEATURES,
WORKER_PROTOCOL_MAX_FEATURE_LENGTH,
@@ -431,6 +445,10 @@ import {
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
WORKER_PROTOCOL_METHODS,
WORKER_RPC_SET_VERSION,
WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES,
WORKER_TRANSCRIPT_MAX_CONTENT_PARTS,
WORKER_TRANSCRIPT_MAX_JSON_DEPTH,
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
type SystemInfoParams,
SystemInfoParamsSchema,
type SystemInfoResult,
@@ -861,7 +879,10 @@ export type ProtocolValidator<T = unknown> = ((data: unknown) => data is T) & {
// Defer TypeBox compilation until the first validation call. Importing this
// module is common in CLIs/tests, so eager compilation would add startup cost.
function lazyCompile<T = unknown>(schema: unknown): ProtocolValidator<T> {
function lazyCompile<T = unknown>(
schema: unknown,
precheck?: (data: unknown) => ValidationError | undefined,
): ProtocolValidator<T> {
let compiled: TypeBoxValidator | undefined;
let errors: ValidationError[] | null = null;
@@ -871,6 +892,11 @@ function lazyCompile<T = unknown>(schema: unknown): ProtocolValidator<T> {
};
const validate = ((data: unknown): data is T => {
const precheckError = precheck?.(data);
if (precheckError) {
errors = [precheckError];
return false;
}
const current = getCompiled();
const valid = current.Check(data);
errors = valid ? null : ([...current.Errors(data)] as ValidationError[]);
@@ -910,6 +936,56 @@ export const validateWorkerConnectRequestFrame = lazyCompile<WorkerConnectReques
export const validateWorkerHeartbeatParams = lazyCompile<WorkerHeartbeatParams>(
WorkerHeartbeatParamsSchema,
);
function checkWorkerTranscriptCommitJson(data: unknown): ValidationError | undefined {
const stack: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: data }];
const seen = new WeakSet<object>();
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
break;
}
if (current.depth > WORKER_TRANSCRIPT_MAX_JSON_DEPTH) {
return {
keyword: "maxDepth",
params: { limit: WORKER_TRANSCRIPT_MAX_JSON_DEPTH },
message: `must not exceed JSON nesting depth ${WORKER_TRANSCRIPT_MAX_JSON_DEPTH}`,
};
}
if (
current.value === null ||
typeof current.value === "string" ||
typeof current.value === "boolean"
) {
continue;
}
if (typeof current.value === "number") {
if (!Number.isFinite(current.value)) {
return { keyword: "finite", message: "must contain only finite JSON numbers" };
}
continue;
}
if (typeof current.value !== "object") {
return { keyword: "jsonValue", message: "must contain only JSON values" };
}
if (seen.has(current.value)) {
return { keyword: "acyclic", message: "must be an acyclic JSON value" };
}
seen.add(current.value);
const values = Array.isArray(current.value)
? current.value
: Object.values(current.value as Record<string, unknown>);
for (const value of values) {
stack.push({ depth: current.depth + 1, value });
}
}
return undefined;
}
export const validateWorkerTranscriptCommitParams = lazyCompile<WorkerTranscriptCommitParams>(
WorkerTranscriptCommitParamsSchema,
checkWorkerTranscriptCommitJson,
);
export const validateGatewaySuspendPrepareParams = lazyCompile<GatewaySuspendPrepareParams>(
GatewaySuspendPrepareParamsSchema,
);
@@ -1517,6 +1593,13 @@ export {
WorkerHeartbeatRequestFrameSchema,
WorkerHeartbeatResponseFrameSchema,
WorkerProtocolCloseReasonSchema,
WorkerTranscriptCommitErrorReasonSchema,
WorkerTranscriptCommitErrorShapeSchema,
WorkerTranscriptCommitParamsSchema,
WorkerTranscriptCommitRequestFrameSchema,
WorkerTranscriptCommitResponseFrameSchema,
WorkerTranscriptCommitResultSchema,
WorkerTranscriptMessageSchema,
WORKER_HEARTBEAT_INTERVAL_MS,
WORKER_PROTOCOL_FEATURES,
WORKER_PROTOCOL_MAX_FEATURE_LENGTH,
@@ -1527,6 +1610,10 @@ export {
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
WORKER_PROTOCOL_METHODS,
WORKER_RPC_SET_VERSION,
WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES,
WORKER_TRANSCRIPT_MAX_CONTENT_PARTS,
WORKER_TRANSCRIPT_MAX_JSON_DEPTH,
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
EnvironmentStatusSchema,
WorkerEnvironmentStateSchema,
WorkerTunnelStatusSchema,
@@ -1902,6 +1989,13 @@ export type {
WorkerHeartbeatResponseFrame,
WorkerHelloOk,
WorkerProtocolCloseReason,
WorkerTranscriptCommitErrorReason,
WorkerTranscriptCommitErrorShape,
WorkerTranscriptCommitParams,
WorkerTranscriptCommitRequestFrame,
WorkerTranscriptCommitResponseFrame,
WorkerTranscriptCommitResult,
WorkerTranscriptMessage,
GatewaySuspendTaskBlocker,
GatewaySuspendBlocker,
GatewaySuspendPrepareParams,
@@ -7,10 +7,14 @@ import {
WorkerHeartbeatRequestFrameSchema,
WorkerHeartbeatResponseFrameSchema,
WorkerProtocolCloseReasonSchema,
WorkerTranscriptCommitRequestFrameSchema,
WorkerTranscriptCommitResponseFrameSchema,
WORKER_RPC_SET_VERSION,
WORKER_TRANSCRIPT_MAX_JSON_DEPTH,
validateWorkerAdmissionHandshake,
validateWorkerConnectRequestFrame,
validateWorkerHeartbeatParams,
validateWorkerTranscriptCommitParams,
} from "../index.js";
const bundleHash = "a".repeat(64);
@@ -49,6 +53,46 @@ const workerHello = {
credentialExpiresAtMs: 10_000,
policy: { heartbeatIntervalMs: 15_000, maxPayload: 1_024 },
};
const usage = {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const transcriptMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: "run the probe" }],
timestamp: 1,
},
{
role: "assistant" as const,
content: [
{
type: "toolCall" as const,
id: "call-1",
name: "probe",
arguments: { value: 1 },
},
],
api: "responses",
provider: "fixture",
model: "fixture-model",
usage,
stopReason: "toolUse" as const,
timestamp: 2,
},
{
role: "toolResult" as const,
toolCallId: "call-1",
toolName: "probe",
content: [{ type: "text" as const, text: "ok" }],
isError: false,
timestamp: 3,
},
];
describe("worker admission handshake schema", () => {
it("accepts the bootstrap receipt and future unique feature names", () => {
@@ -112,6 +156,139 @@ describe("worker protocol schemas", () => {
expect(Value.Check(WorkerHeartbeatResponseFrameSchema, response)).toBe(true);
});
it("accepts semantic transcript commits and generated-id responses", () => {
const params = {
runEpoch: 2,
seq: 1,
baseLeafId: null,
messages: transcriptMessages,
};
expect(validateWorkerTranscriptCommitParams(params)).toBe(true);
expect(
Value.Check(WorkerTranscriptCommitRequestFrameSchema, {
type: "req",
id: "commit-1",
method: "worker.transcript.commit",
params,
}),
).toBe(true);
expect(
Value.Check(WorkerTranscriptCommitResponseFrameSchema, {
type: "res",
id: "commit-1",
ok: true,
payload: { entryIds: ["entry-1", "entry-2", "entry-3"], newLeafId: "entry-3" },
}),
).toBe(true);
expect(
Value.Check(WorkerTranscriptCommitResponseFrameSchema, {
type: "res",
id: "commit-1",
ok: false,
error: {
code: "INVALID_REQUEST",
message: "worker request rejected",
details: { reason: "credential-replaced" },
},
}),
).toBe(true);
expect(
Value.Check(WorkerTranscriptCommitResponseFrameSchema, {
type: "res",
id: "commit-1",
ok: false,
error: {
code: "INVALID_REQUEST",
message: "transcript commit rejected",
details: { reason: "stale-base-leaf" },
},
}),
).toBe(true);
});
it.each([
{ runEpoch: 2, seq: 1, baseLeafId: null, messages: [] },
{ runEpoch: 2, seq: 0, baseLeafId: null, messages: transcriptMessages },
{ runEpoch: 2, seq: 1, baseLeafId: null, messages: transcriptMessages, sessionId: "other" },
{
runEpoch: 2,
seq: 1,
baseLeafId: null,
messages: [{ ...transcriptMessages[0], id: "entry-from-worker" }],
},
{
runEpoch: 2,
seq: 1,
baseLeafId: null,
messages: [{ ...transcriptMessages[0], parentId: "parent-from-worker" }],
},
{
runEpoch: 2,
seq: 1,
baseLeafId: null,
messages: [{ ...transcriptMessages[0], sessionId: "foreign-session" }],
},
])("rejects raw transcript identity or invalid batch fields %#", (candidate) => {
expect(validateWorkerTranscriptCommitParams(candidate)).toBe(false);
});
it("rejects deeply nested worker JSON before schema compilation", () => {
let nested: unknown = "leaf";
for (let depth = 0; depth <= WORKER_TRANSCRIPT_MAX_JSON_DEPTH; depth += 1) {
nested = { nested };
}
const assistant = transcriptMessages[1];
if (!assistant || assistant.role !== "assistant") {
throw new Error("expected assistant transcript fixture");
}
const candidate = {
runEpoch: 2,
seq: 1,
baseLeafId: null,
messages: [
{
...assistant,
content: [
{
type: "toolCall" as const,
id: "call-deep",
name: "probe",
arguments: { nested },
},
],
},
],
};
expect(validateWorkerTranscriptCommitParams(candidate)).toBe(false);
expect(validateWorkerTranscriptCommitParams.errors?.[0]).toMatchObject({
keyword: "maxDepth",
params: { limit: WORKER_TRANSCRIPT_MAX_JSON_DEPTH },
});
});
it("rejects non-finite numbers parsed from worker JSON", () => {
const candidate = JSON.parse(`{
"runEpoch": 2,
"seq": 1,
"baseLeafId": null,
"messages": [{
"role": "toolResult",
"toolCallId": "call-non-finite",
"toolName": "probe",
"content": [],
"details": { "value": 1e400 },
"isError": false,
"timestamp": 1
}]
}`) as unknown;
expect(validateWorkerTranscriptCommitParams(candidate)).toBe(false);
expect(validateWorkerTranscriptCommitParams.errors?.[0]).toMatchObject({
keyword: "finite",
});
});
it("keeps worker close reasons closed", () => {
expect(Value.Check(WorkerProtocolCloseReasonSchema, "credential-replaced")).toBe(true);
expect(Value.Check(WorkerProtocolCloseReasonSchema, "not-a-worker-reason")).toBe(false);
@@ -1,16 +1,24 @@
import { Type, type Static } from "typebox";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js";
// Additive RPCs require exact build-bound features; bump only for an incompatible base set.
export const WORKER_RPC_SET_VERSION = 1;
export const WORKER_HEARTBEAT_INTERVAL_MS = 15_000;
export const WORKER_PROTOCOL_METHODS = ["worker.heartbeat"] as const;
export const WORKER_PROTOCOL_FEATURES = ["worker-heartbeat-v1"] as const;
export const WORKER_PROTOCOL_METHODS = ["worker.heartbeat", "worker.transcript.commit"] as const;
export const WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE = "worker-transcript-commit-v1";
export const WORKER_PROTOCOL_FEATURES = [
"worker-heartbeat-v1",
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
] as const;
export const WORKER_PROTOCOL_MAX_IDENTIFIER_LENGTH = 256;
export const WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH = 128;
export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64;
export const WORKER_PROTOCOL_MAX_PAYLOAD_BYTES = 64 * 1024;
export const WORKER_PROTOCOL_MAX_FEATURES = 64;
export const WORKER_PROTOCOL_MAX_FEATURE_LENGTH = 128;
export const WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES = 64;
export const WORKER_TRANSCRIPT_MAX_CONTENT_PARTS = 128;
export const WORKER_TRANSCRIPT_MAX_JSON_DEPTH = 32;
const WorkerIdentifierSchema = Type.String({
minLength: 1,
@@ -229,6 +237,262 @@ export const WorkerHeartbeatResponseFrameSchema = Type.Union([
WorkerErrorResponseFrameSchema,
]);
const WorkerTranscriptTextContentSchema = Type.Object(
{
type: Type.Literal("text"),
text: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
textSignature: Type.Optional(
Type.String({ minLength: 1, maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
),
},
{ additionalProperties: false },
);
const WorkerTranscriptThinkingContentSchema = Type.Object(
{
type: Type.Literal("thinking"),
thinking: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
thinkingSignature: Type.Optional(
Type.String({ minLength: 1, maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
),
redacted: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
const WorkerTranscriptImageContentSchema = Type.Object(
{
type: Type.Literal("image"),
data: Type.String({ minLength: 1, maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
mimeType: Type.String({ minLength: 1, maxLength: 256 }),
},
{ additionalProperties: false },
);
const WorkerTranscriptToolCallSchema = Type.Object(
{
type: Type.Literal("toolCall"),
id: WorkerIdentifierSchema,
name: WorkerIdentifierSchema,
arguments: Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()),
thoughtSignature: Type.Optional(
Type.String({ minLength: 1, maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
),
executionMode: Type.Optional(
Type.Union([Type.Literal("sequential"), Type.Literal("parallel")]),
),
},
{ additionalProperties: false },
);
const WorkerTranscriptUsageSchema = Type.Object(
{
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
contextUsage: Type.Optional(
Type.Union([
Type.Object(
{
state: Type.Literal("available"),
promptTokens: Type.Number({ minimum: 0 }),
totalTokens: Type.Number({ minimum: 0 }),
},
{ additionalProperties: false },
),
Type.Object({ state: Type.Literal("unavailable") }, { additionalProperties: false }),
]),
),
totalTokens: Type.Number({ minimum: 0 }),
cost: Type.Object(
{
input: Type.Number({ minimum: 0 }),
output: Type.Number({ minimum: 0 }),
cacheRead: Type.Number({ minimum: 0 }),
cacheWrite: Type.Number({ minimum: 0 }),
total: Type.Number({ minimum: 0 }),
totalOrigin: Type.Optional(Type.Literal("provider-billed")),
},
{ additionalProperties: false },
),
},
{ additionalProperties: false },
);
const WorkerTranscriptAssistantDiagnosticSchema = Type.Object(
{
type: WorkerIdentifierSchema,
timestamp: Type.Integer({ minimum: 0 }),
error: Type.Optional(
Type.Object(
{
name: Type.Optional(Type.String({ maxLength: 256 })),
message: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
stack: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })),
code: Type.Optional(Type.Union([Type.String({ maxLength: 256 }), Type.Number()])),
},
{ additionalProperties: false },
),
),
details: Type.Optional(
Type.Record(Type.String({ minLength: 1, maxLength: 256 }), Type.Unknown()),
),
},
{ additionalProperties: false },
);
const WorkerTranscriptUserMessageSchema = Type.Object(
{
role: Type.Literal("user"),
content: Type.Array(
Type.Union([WorkerTranscriptTextContentSchema, WorkerTranscriptImageContentSchema]),
{ minItems: 1, maxItems: WORKER_TRANSCRIPT_MAX_CONTENT_PARTS },
),
timestamp: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
const WorkerTranscriptAssistantMessageSchema = Type.Object(
{
role: Type.Literal("assistant"),
content: Type.Array(
Type.Union([
WorkerTranscriptTextContentSchema,
WorkerTranscriptThinkingContentSchema,
WorkerTranscriptToolCallSchema,
]),
{ maxItems: WORKER_TRANSCRIPT_MAX_CONTENT_PARTS },
),
api: WorkerIdentifierSchema,
provider: WorkerIdentifierSchema,
model: WorkerIdentifierSchema,
responseModel: Type.Optional(WorkerIdentifierSchema),
responseId: Type.Optional(WorkerIdentifierSchema),
diagnostics: Type.Optional(
Type.Array(WorkerTranscriptAssistantDiagnosticSchema, {
maxItems: WORKER_TRANSCRIPT_MAX_CONTENT_PARTS,
}),
),
usage: WorkerTranscriptUsageSchema,
stopReason: Type.Union([
Type.Literal("stop"),
Type.Literal("length"),
Type.Literal("toolUse"),
Type.Literal("error"),
Type.Literal("aborted"),
]),
errorMessage: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })),
errorCode: Type.Optional(Type.String({ maxLength: 256 })),
errorType: Type.Optional(Type.String({ maxLength: 256 })),
errorBody: Type.Optional(Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES })),
timestamp: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
const WorkerTranscriptToolResultMessageSchema = Type.Object(
{
role: Type.Literal("toolResult"),
toolCallId: WorkerIdentifierSchema,
toolName: WorkerIdentifierSchema,
content: Type.Array(
Type.Union([WorkerTranscriptTextContentSchema, WorkerTranscriptImageContentSchema]),
{ maxItems: WORKER_TRANSCRIPT_MAX_CONTENT_PARTS },
),
details: Type.Optional(Type.Unknown()),
isError: Type.Boolean(),
timestamp: Type.Integer({ minimum: 0 }),
},
{ additionalProperties: false },
);
export const WorkerTranscriptMessageSchema = Type.Union([
WorkerTranscriptUserMessageSchema,
WorkerTranscriptAssistantMessageSchema,
WorkerTranscriptToolResultMessageSchema,
]);
export const WorkerTranscriptCommitParamsSchema = Type.Object(
{
runEpoch: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
seq: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
baseLeafId: Type.Union([WorkerIdentifierSchema, Type.Null()]),
messages: Type.Array(WorkerTranscriptMessageSchema, {
minItems: 1,
maxItems: WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES,
}),
},
{ additionalProperties: false },
);
export const WorkerTranscriptCommitResultSchema = Type.Object(
{
entryIds: Type.Array(WorkerIdentifierSchema, {
minItems: 1,
maxItems: WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES,
}),
newLeafId: WorkerIdentifierSchema,
},
{ additionalProperties: false },
);
export const WorkerTranscriptCommitErrorReasonSchema = Type.Union([
Type.Literal("stale-base-leaf"),
Type.Literal("epoch-mismatch"),
Type.Literal("invalid-batch"),
Type.Literal("session-not-attached"),
]);
export const WorkerTranscriptCommitErrorShapeSchema = Type.Object(
{
code: Type.Literal("INVALID_REQUEST"),
message: Type.String({ minLength: 1, maxLength: 256 }),
details: Type.Object(
{ reason: WorkerTranscriptCommitErrorReasonSchema },
{ additionalProperties: false },
),
},
{ additionalProperties: false },
);
export const WorkerTranscriptCommitRequestFrameSchema = Type.Object(
{
type: Type.Literal("req"),
id: WorkerFrameIdSchema,
method: Type.Literal(WORKER_PROTOCOL_METHODS[1]),
params: WorkerTranscriptCommitParamsSchema,
},
{ additionalProperties: false },
);
const WorkerTranscriptCommitSuccessResponseFrameSchema = Type.Object(
{
type: Type.Literal("res"),
id: WorkerFrameIdSchema,
ok: Type.Literal(true),
payload: WorkerTranscriptCommitResultSchema,
},
{ additionalProperties: false },
);
const WorkerTranscriptCommitErrorResponseFrameSchema = Type.Object(
{
type: Type.Literal("res"),
id: WorkerFrameIdSchema,
ok: Type.Literal(false),
error: WorkerTranscriptCommitErrorShapeSchema,
},
{ additionalProperties: false },
);
export const WorkerTranscriptCommitResponseFrameSchema = Type.Union([
WorkerTranscriptCommitSuccessResponseFrameSchema,
WorkerTranscriptCommitErrorResponseFrameSchema,
WorkerErrorResponseFrameSchema,
]);
export type WorkerAdmissionHandshake = Static<typeof WorkerAdmissionHandshakeSchema>;
export type WorkerConnectParams = Static<typeof WorkerConnectParamsSchema>;
export type WorkerConnectRequestFrame = Static<typeof WorkerConnectRequestFrameSchema>;
@@ -241,3 +505,18 @@ export type WorkerHeartbeatParams = Static<typeof WorkerHeartbeatParamsSchema>;
export type WorkerHeartbeatResult = Static<typeof WorkerHeartbeatResultSchema>;
export type WorkerHeartbeatRequestFrame = Static<typeof WorkerHeartbeatRequestFrameSchema>;
export type WorkerHeartbeatResponseFrame = Static<typeof WorkerHeartbeatResponseFrameSchema>;
export type WorkerTranscriptMessage = Static<typeof WorkerTranscriptMessageSchema>;
export type WorkerTranscriptCommitParams = Static<typeof WorkerTranscriptCommitParamsSchema>;
export type WorkerTranscriptCommitResult = Static<typeof WorkerTranscriptCommitResultSchema>;
export type WorkerTranscriptCommitErrorReason = Static<
typeof WorkerTranscriptCommitErrorReasonSchema
>;
export type WorkerTranscriptCommitErrorShape = Static<
typeof WorkerTranscriptCommitErrorShapeSchema
>;
export type WorkerTranscriptCommitRequestFrame = Static<
typeof WorkerTranscriptCommitRequestFrameSchema
>;
export type WorkerTranscriptCommitResponseFrame = Static<
typeof WorkerTranscriptCommitResponseFrameSchema
>;
+14 -3
View File
@@ -51,6 +51,7 @@ import {
publishOwnedSessionFileSnapshot,
} from "../../config/sessions/transcript-write-context.js";
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ImageContent, Message, TextContent } from "../../llm/types.js";
import { logWarn } from "../../logger.js";
import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
@@ -216,6 +217,8 @@ export type SessionEntry =
export type FileEntry = SessionHeader | SessionEntry;
type AppendPersistenceOptions = {
config?: OpenClawConfig;
idempotencyLookup?: "scan" | "scan-assistant" | "caller-checked";
invalidateSerializedPrefixCache?: boolean;
};
@@ -2245,7 +2248,7 @@ export class SessionManager {
publishSnapshot = true,
): void {
if (this.sqlitePersistence) {
this.persistSqliteRecord(entry);
this.persistSqliteRecord(entry, options);
return;
}
if (!this.shouldPersist || !this.sessionFile) {
@@ -2319,7 +2322,7 @@ export class SessionManager {
this.persistRecord(entry, options);
}
private persistSqliteRecord(entry: unknown): void {
private persistSqliteRecord(entry: unknown, options?: AppendPersistenceOptions): void {
if (!isIndexedSessionEntry(entry)) {
return;
}
@@ -2337,13 +2340,21 @@ export class SessionManager {
appendTranscriptEventSync(scope, entry);
return;
}
appendTranscriptMessageSync(scope, {
const result = appendTranscriptMessageSync(scope, {
cwd: this.cwd,
eventId: entry.id,
...(options?.config ? { config: options.config } : {}),
...(options?.idempotencyLookup ? { idempotencyLookup: options.idempotencyLookup } : {}),
message: entry.message,
now: Date.parse(entry.timestamp),
parentId: entry.parentId,
});
if (
options?.idempotencyLookup === "caller-checked" &&
(!result?.appended || result.messageId !== entry.id)
) {
throw new Error(`Session transcript append was not persisted: ${entry.id}`);
}
}
/**
@@ -1943,6 +1943,21 @@ export async function withSqliteTranscriptWriteLock<T>(
});
}
/** Runs synchronous transcript work under one writer queue and SQLite transaction. */
export async function withSqliteTranscriptWriteTransaction<T>(
scope: SessionTranscriptWriteScope,
run: (context: { sessionFile: string }) => T,
): Promise<T> {
const resolved = resolveSqliteTranscriptScope(scope);
return await runExclusiveSqliteSessionWrite(resolved, async () =>
runOpenClawAgentWriteTransaction(
() => run({ sessionFile: formatSqliteSessionMarkerForScope(resolved) }),
toDatabaseOptions(resolved),
{ operationLabel: "session.transcript.batch" },
),
);
}
function appendSqliteTranscriptMessageInTransaction<TMessage>(
database: OpenClawAgentDatabase,
resolved: ResolvedTranscriptScope,
+14
View File
@@ -71,6 +71,7 @@ import {
updateSqliteSessionEntry,
upsertSqliteSessionEntry,
withSqliteTranscriptWriteLock,
withSqliteTranscriptWriteTransaction,
} from "./session-accessor.sqlite.js";
import {
formatSqliteSessionFileMarker,
@@ -349,6 +350,11 @@ export type SessionTranscriptWriteLockAccessorContext = {
replaceEvents: (events: readonly TranscriptEvent[]) => Promise<void>;
};
export type SessionTranscriptWriteTransactionContext = {
/** Canonical marker for the same agent database owned by the transaction. */
sessionFile: string;
};
export type SessionTranscriptTurnUpdateMode = "inline" | "file-only" | "none";
export type SessionTranscriptTurnMessageAppend = TranscriptMessageAppendOptions<unknown> & {
@@ -2435,6 +2441,14 @@ export async function withTranscriptWriteLock<T>(
return await withSqliteTranscriptWriteLock(scope, run);
}
/** Runs a synchronous DAG batch under one transcript writer queue and transaction. */
export async function withTranscriptWriteTransaction<T>(
scope: SessionTranscriptWriteScope,
run: (context: SessionTranscriptWriteTransactionContext) => T,
): Promise<T> {
return await withSqliteTranscriptWriteTransaction(scope, run);
}
/**
* Trims a transcript for manual sessions.compact and clears stale token metadata.
* This is one storage-sized mutation: future stores can trim transcript rows and
+4
View File
@@ -138,6 +138,7 @@ import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-
import type { WorkerBundleProducer, WorkerNpmArtifact } from "./worker-environments/bundle.js";
import { createWorkerEnvironmentService } from "./worker-environments/service.js";
import { createWorkerEnvironmentStore } from "./worker-environments/store.js";
import { createWorkerTranscriptCommitter } from "./worker-environments/transcript-commit.js";
type LoadGatewayModelCatalog = typeof import("./server-model-catalog.js").loadGatewayModelCatalog;
type LoadGatewayModelCatalogSnapshot =
@@ -795,6 +796,9 @@ export async function startGatewayServer(
prepareInstallation: prepareWorkerInstallation,
tunnelManager: workerTunnelManager,
resolveWorkerGateway: () => resolveWorkerGatewayEndpoint(),
applyTranscriptCommit: createWorkerTranscriptCommitter({
getConfig: getRuntimeConfig,
}).commit,
resolveSshIdentity: async ({ provider, leaseId, profile, keyRef }) => {
const workerEnvironmentRuntime = await loadWorkerEnvironmentRuntimeModule();
return await workerEnvironmentRuntime.resolveWorkerSshIdentity({
@@ -8,6 +8,8 @@ import {
PROTOCOL_VERSION,
type WorkerAdmissionFailureReason,
type WorkerConnectParams,
type WorkerTranscriptCommitErrorReason,
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
} from "../../../../packages/gateway-protocol/src/index.js";
import {
resetGatewayWorkAdmission,
@@ -22,7 +24,7 @@ const CREDENTIAL = ["worker", "credential", "fixture"].join("-");
const HANDSHAKE = {
bundleHash: "a".repeat(64),
openclawVersion: "2026.7.11",
protocolFeatures: ["worker-heartbeat-v1"],
protocolFeatures: ["worker-heartbeat-v1", WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE],
};
const WORKER_CONNECT: WorkerConnectParams = {
minProtocol: PROTOCOL_VERSION,
@@ -53,6 +55,18 @@ const IDENTITY: WorkerConnectionIdentity = {
protocolFeatures: [...HANDSHAKE.protocolFeatures],
credentialExpiresAtMs: Date.now() + 60_000,
};
const TRANSCRIPT_COMMIT = {
runEpoch: 1,
seq: 1,
baseLeafId: null,
messages: [
{
role: "user" as const,
content: [{ type: "text" as const, text: "hello" }],
timestamp: 1,
},
],
};
const cleanups: Array<() => void> = [];
function createLogger() {
@@ -62,6 +76,8 @@ function createLogger() {
function attachHarness(
options: {
admissionFailure?: WorkerAdmissionFailureReason;
commitFailure?: WorkerTranscriptCommitErrorReason;
identity?: WorkerConnectionIdentity;
validationFailure?: ReturnType<WorkerConnectionService["validateWorkerConnection"]>;
} = {},
) {
@@ -72,7 +88,15 @@ function attachHarness(
admitWorker: vi.fn(async () =>
options.admissionFailure
? { ok: false as const, reason: options.admissionFailure }
: { ok: true as const, identity: IDENTITY },
: { ok: true as const, identity: options.identity ?? IDENTITY },
),
commitTranscript: vi.fn(async () =>
options.commitFailure
? { ok: false as const, reason: options.commitFailure }
: {
ok: true as const,
result: { entryIds: ["entry-1"], newLeafId: "entry-1" },
},
),
validateWorkerConnection: vi.fn(() => options.validationFailure ?? null),
} as WorkerConnectionService;
@@ -83,6 +107,7 @@ function attachHarness(
});
const logGateway = createLogger();
const logWsControl = createLogger();
const setLastFrameMeta = vi.fn();
const cleanup = attachWorkerWsMessageHandler({
socket: socket as unknown as WebSocket,
connId: "worker-connection",
@@ -96,7 +121,7 @@ function attachHarness(
setHandshakeState: vi.fn(),
advanceHandshakePhase: vi.fn(),
setCloseCause: vi.fn(),
setLastFrameMeta: vi.fn(),
setLastFrameMeta,
logGateway,
logWsControl,
});
@@ -110,6 +135,7 @@ function attachHarness(
responses,
service,
setClient,
setLastFrameMeta,
sendRequest: (method: string, params: unknown) =>
send({ type: "req", id: "request-1", method, params }),
sendConnect: () =>
@@ -181,6 +207,83 @@ describe("dedicated worker websocket protocol", () => {
});
});
it("dispatches semantic transcript commits on the closed worker allowlist", async () => {
const harness = attachHarness();
await admit(harness);
harness.sendRequest("worker.transcript.commit", TRANSCRIPT_COMMIT);
await vi.waitFor(() => expect(harness.responses).toHaveLength(2));
expect(harness.responses[1]).toMatchObject({
ok: true,
payload: { entryIds: ["entry-1"], newLeafId: "entry-1" },
});
expect(harness.service.commitTranscript).toHaveBeenCalledWith(IDENTITY, TRANSCRIPT_COMMIT);
expect(harness.setLastFrameMeta).toHaveBeenLastCalledWith({
type: "req",
method: "worker.transcript.commit",
});
expect(harness.close).not.toHaveBeenCalled();
});
it("rejects transcript commits when the admitted worker lacks the feature", async () => {
const harness = attachHarness({
identity: { ...IDENTITY, protocolFeatures: ["worker-heartbeat-v1"] },
});
await admit(harness);
harness.sendRequest("worker.transcript.commit", TRANSCRIPT_COMMIT);
await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "method-not-allowed"));
expect(harness.service.commitTranscript).not.toHaveBeenCalled();
});
it("returns closed transcript errors without closing the worker connection", async () => {
const harness = attachHarness({ commitFailure: "stale-base-leaf" });
await admit(harness);
harness.sendRequest("worker.transcript.commit", TRANSCRIPT_COMMIT);
await vi.waitFor(() => expect(harness.responses).toHaveLength(2));
expect(harness.responses[1]).toMatchObject({
ok: false,
error: { details: { reason: "stale-base-leaf" } },
});
expect(harness.close).not.toHaveBeenCalled();
});
it("rejects structurally invalid transcript batches before application", async () => {
const harness = attachHarness();
await admit(harness);
harness.sendRequest("worker.transcript.commit", {
...TRANSCRIPT_COMMIT,
sessionId: "foreign-session",
});
await vi.waitFor(() => expect(harness.responses).toHaveLength(2));
expect(harness.responses[1]).toMatchObject({
ok: false,
error: { details: { reason: "invalid-batch" } },
});
expect(harness.service.commitTranscript).not.toHaveBeenCalled();
expect(harness.close).not.toHaveBeenCalled();
});
it("closes a replaced worker before parsing a malformed transcript batch", async () => {
const harness = attachHarness();
await admit(harness);
vi.mocked(harness.service.validateWorkerConnection).mockReturnValue("credential-replaced");
harness.sendRequest("worker.transcript.commit", {
...TRANSCRIPT_COMMIT,
sessionId: "foreign-session",
});
await vi.waitFor(() => expect(harness.responses).toHaveLength(2));
expect(harness.responses[1]).toMatchObject({
ok: false,
error: { details: { reason: "credential-replaced" } },
});
await vi.waitFor(() => expect(harness.close).toHaveBeenCalledWith(1008, "credential-replaced"));
expect(harness.service.commitTranscript).not.toHaveBeenCalled();
});
it("revalidates ownership immediately before admission", async () => {
const harness = attachHarness({ validationFailure: "credential-replaced" });
harness.sendConnect();
@@ -8,14 +8,18 @@ import {
type WorkerHeartbeatResult,
type WorkerHelloOk,
type WorkerProtocolCloseReason,
type WorkerTranscriptCommitErrorReason,
type WorkerTranscriptCommitErrorShape,
WORKER_HEARTBEAT_INTERVAL_MS,
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
WORKER_PROTOCOL_MAX_METHOD_LENGTH,
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
WORKER_PROTOCOL_METHODS,
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
validateRequestFrame,
validateWorkerConnectRequestFrame,
validateWorkerHeartbeatParams,
validateWorkerTranscriptCommitParams,
} from "../../../../packages/gateway-protocol/src/index.js";
import { GATEWAY_STARTUP_RETRY_AFTER_MS } from "../../../../packages/gateway-protocol/src/startup-unavailable.js";
import { rawDataToString } from "../../../infra/ws.js";
@@ -26,10 +30,14 @@ import type { GatewayWsClient, WsHandshakePhase } from "../ws-types.js";
export type WorkerConnectionService = Pick<
WorkerEnvironmentService,
"admitWorker" | "validateWorkerConnection"
"admitWorker" | "commitTranscript" | "validateWorkerConnection"
>;
type WorkerRespond = (ok: boolean, payload?: unknown, error?: WorkerErrorShape) => void;
type WorkerRespond = (
ok: boolean,
payload?: unknown,
error?: WorkerErrorShape | WorkerTranscriptCommitErrorShape,
) => void;
type WorkerLogger = { warn(message: string): void };
const MAX_QUEUED_WORKER_FRAMES = 16;
@@ -97,22 +105,56 @@ function rejectWorkerRequest(params: {
queueMicrotask(() => params.close(1008, params.reason));
}
function workerTranscriptCommitError(
reason: WorkerTranscriptCommitErrorReason,
): WorkerTranscriptCommitErrorShape {
return {
code: ErrorCodes.INVALID_REQUEST,
message: "worker transcript commit rejected",
details: { reason },
};
}
/** Closed worker dispatcher. It never calls the generic gateway method registry. */
function dispatchWorkerRequest(params: {
async function dispatchWorkerRequest(params: {
request: RequestFrame;
identity: WorkerConnectionIdentity;
service: WorkerConnectionService | undefined;
respond: WorkerRespond;
close(code: number, reason: WorkerProtocolCloseReason): void;
warn(message: string): void;
}): void {
const ownershipFailure = params.service
? params.service.validateWorkerConnection(params.identity)
: "environment-unavailable";
}): Promise<void> {
const service = params.service;
if (!service) {
rejectWorkerRequest({ ...params, reason: "environment-unavailable" });
return;
}
const ownershipFailure = service.validateWorkerConnection(params.identity);
if (ownershipFailure) {
rejectWorkerRequest({ ...params, reason: ownershipFailure });
return;
}
if (params.request.method === WORKER_PROTOCOL_METHODS[1]) {
if (!params.identity.protocolFeatures.includes(WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE)) {
rejectWorkerRequest({ ...params, reason: "method-not-allowed" });
return;
}
if (!validateWorkerTranscriptCommitParams(params.request.params)) {
params.respond(false, undefined, workerTranscriptCommitError("invalid-batch"));
return;
}
const outcome = await service.commitTranscript(params.identity, params.request.params);
if (outcome.ok) {
params.respond(true, outcome.result);
return;
}
if ("closeReason" in outcome) {
rejectWorkerRequest({ ...params, reason: outcome.closeReason });
return;
}
params.respond(false, undefined, workerTranscriptCommitError(outcome.reason));
return;
}
if (params.request.method !== WORKER_PROTOCOL_METHODS[0]) {
rejectWorkerRequest({ ...params, reason: "method-not-allowed" });
return;
@@ -298,14 +340,17 @@ export function attachWorkerWsMessageHandler(params: WorkerWsMessageHandlerParam
closeWorker(1008, "invalid-frame");
return;
}
if (parsed.method === WORKER_PROTOCOL_METHODS[0]) {
params.setLastFrameMeta({ type: "req", method: WORKER_PROTOCOL_METHODS[0] });
if (
parsed.method === WORKER_PROTOCOL_METHODS[0] ||
parsed.method === WORKER_PROTOCOL_METHODS[1]
) {
params.setLastFrameMeta({ type: "req", method: parsed.method });
}
if (!client.worker) {
closeWorker(1008, "environment-unavailable");
return;
}
dispatchWorkerRequest({
await dispatchWorkerRequest({
request: parsed,
identity: client.worker,
service: params.service,
@@ -10,6 +10,7 @@ import {
persistSessionTranscriptTurn,
} from "../config/sessions/session-accessor.js";
import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import * as transcriptEvents from "../sessions/transcript-events.js";
import {
@@ -25,6 +26,9 @@ import {
rpcReq,
writeSessionStore,
} from "./test-helpers.server.js";
import type { WorkerConnectionIdentity } from "./worker-environments/connection-identity.js";
import type { WorkerTranscriptCommitStore } from "./worker-environments/transcript-commit-store.js";
import { createWorkerTranscriptCommitter } from "./worker-environments/transcript-commit.js";
installGatewayTestHooks({ scope: "suite" });
@@ -1210,6 +1214,97 @@ describe("session.message websocket events", () => {
}
});
test("streams every worker transcript commit entry to the selected session subscriber", async () => {
const storePath = await createSessionStoreFile();
const sessionId = "sess-worker-commit-fanout";
const sessionKey = "agent:main:worker";
await writeSessionStore({
entries: {
worker: {
sessionId,
updatedAt: Date.now(),
},
},
storePath,
});
const config: OpenClawConfig = {
agents: { list: [{ id: "main", default: true }] },
session: { mainKey: "main", store: storePath },
};
const ledger: WorkerTranscriptCommitStore = {
begin: () => ({ kind: "claimed" }),
complete: ({ outcome }) => outcome,
};
const committer = createWorkerTranscriptCommitter({ getConfig: () => config, store: ledger });
const identity: WorkerConnectionIdentity = {
environmentId: "environment-fanout",
credentialHash: ["fanout", "credential", "hash"].join("-"),
bundleHash: "f".repeat(64),
sessionId,
ownerEpoch: 4,
rpcSetVersion: 1,
protocolFeatures: ["worker-transcript-commit-v1"],
credentialExpiresAtMs: Date.now() + 10_000,
};
const ws = await harness.openWs();
try {
await connectOk(ws, { scopes: ["operator.read"] });
const subscribeRes = await rpcReq(ws, "sessions.messages.subscribe", { key: sessionKey });
expect(subscribeRes.ok).toBe(true);
expect(subscribeRes.payload?.subscribed).toBe(true);
expect(subscribeRes.payload?.key).toBe(sessionKey);
const eventPromises = [1, 2, 3].map((messageSeq) =>
onceMessage(
ws,
(message) =>
message.type === "event" &&
message.event === "session.message" &&
(message.payload as { sessionKey?: unknown } | undefined)?.sessionKey === sessionKey &&
(message.payload as { messageSeq?: unknown } | undefined)?.messageSeq === messageSeq,
),
);
const outcome = await committer.commit({
identity,
request: {
runEpoch: identity.ownerEpoch,
seq: 1,
baseLeafId: null,
messages: ["first", "second", "third"].map((text, index) => ({
role: "user" as const,
content: [{ type: "text" as const, text }],
timestamp: 100 + index,
})),
},
});
expect(outcome.ok).toBe(true);
if (!outcome.ok) {
throw new Error(`expected worker transcript commit, received ${outcome.reason}`);
}
const events = await Promise.all(eventPromises);
const payloads = events.map((event) =>
requireRecord(event.payload, "session.message payload"),
);
expect(payloads.map((payload) => payload.messageId)).toEqual(outcome.result.entryIds);
expect(payloads.map((payload) => payload.messageSeq)).toEqual([1, 2, 3]);
expect(
payloads.map((payload) => {
const message = requireRecord(payload.message, "session.message payload message");
return requireRecord(message["__openclaw"], "session.message metadata").id;
}),
).toEqual(outcome.result.entryIds);
expect(
payloads.map((payload) => {
const message = requireRecord(payload.message, "session.message payload message");
return requireRecord(message["__openclaw"], "session.message metadata").seq;
}),
).toEqual([1, 2, 3]);
} finally {
ws.close();
}
});
test("routes transcript-only SQLite marker updates to the matching session owner", async () => {
const storePath = await createSessionStoreFile();
await writeSessionStore({
@@ -14,6 +14,7 @@ import {
type OpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import type { WorkerInstallationArtifact } from "./bundle.js";
import type { WorkerConnectionIdentity } from "./connection-identity.js";
import { hashWorkerCredential } from "./credential.js";
import {
createWorkerEnvironmentService,
@@ -108,6 +109,7 @@ describe("worker environment service", () => {
serviceOptions: Partial<
Pick<
WorkerEnvironmentServiceOptions,
| "applyTranscriptCommit"
| "bootstrapCallTimeoutMs"
| "providerCallTimeoutMs"
| "resolveSshIdentity"
@@ -216,6 +218,33 @@ describe("worker environment service", () => {
};
}
function seedAttachedIdentity(
environmentId: string,
sessionId: string,
): WorkerConnectionIdentity {
const ready = seedReady(environmentId);
const attached = store.transition({
environmentId,
from: ready.state,
to: "attached",
patch: attachedPatch(environmentId, sessionId),
});
const credential = store.getCredential(environmentId);
if (!credential || !attached.bootstrapReceipt) {
throw new Error("attached worker fixture is incomplete");
}
return {
environmentId,
credentialHash: credential.credentialHash,
bundleHash: credential.bundleHash,
sessionId,
ownerEpoch: attached.ownerEpoch,
rpcSetVersion: credential.rpcSetVersion,
protocolFeatures: [...attached.bootstrapReceipt.protocolFeatures],
credentialExpiresAtMs: credential.expiresAtMs,
};
}
it("persists intent and an immutable profile snapshot before provisioning", async () => {
const operationIds: string[] = [];
const provider = createProvider({
@@ -318,6 +347,50 @@ describe("worker environment service", () => {
expect(prepareInstallation).toHaveBeenCalledWith("bundle");
});
it("fences transcript commits by current epoch and exact session credential binding", async () => {
const environmentId = "worker-transcript-fence";
const sessionId = "session-transcript-fence";
const identity = seedAttachedIdentity(environmentId, sessionId);
const applyTranscriptCommit = vi.fn(async () => ({
ok: true as const,
result: { entryIds: ["entry-1"], newLeafId: "entry-1" },
}));
const workerService = createService(createProvider(), { applyTranscriptCommit });
const request = {
runEpoch: identity.ownerEpoch,
seq: 1,
baseLeafId: null,
messages: [
{
role: "user" as const,
content: [{ type: "text" as const, text: "hello" }],
timestamp: 1,
},
],
};
await expect(workerService.commitTranscript(identity, request)).resolves.toMatchObject({
ok: true,
});
expect(applyTranscriptCommit).toHaveBeenCalledOnce();
await expect(
workerService.commitTranscript(identity, {
...request,
runEpoch: identity.ownerEpoch + 1,
seq: 2,
}),
).resolves.toEqual({ ok: false, reason: "epoch-mismatch" });
database.db
.prepare("UPDATE worker_environment_credentials SET session_id = ? WHERE environment_id = ?")
.run("session-other", environmentId);
await expect(workerService.commitTranscript(identity, { ...request, seq: 2 })).resolves.toEqual(
{ ok: false, reason: "session-not-attached" },
);
expect(applyTranscriptCommit).toHaveBeenCalledOnce();
});
it("rejects attach before current bootstrap", async () => {
const staleId = "worker-stale-attach";
const bootstrapping = seedBootstrapping(staleId);
@@ -3,6 +3,10 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import {
type WorkerAdmissionHandshake,
type WorkerConnectParams,
type WorkerProtocolCloseReason,
type WorkerTranscriptCommitErrorReason,
type WorkerTranscriptCommitParams,
type WorkerTranscriptCommitResult,
WORKER_RPC_SET_VERSION,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { OpenClawConfig } from "../../config/types.js";
@@ -22,6 +26,7 @@ import {
type WorkerSshEndpoint,
type WorkerSshIdentity,
} from "../../plugins/types.js";
import { safeEqualSecret } from "../../security/secret-equal.js";
import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js";
import {
admitWorkerConnection,
@@ -98,8 +103,20 @@ export type WorkerEnvironmentServiceOptions = {
resolveWorkerGateway?: () => { host: "127.0.0.1" | "::1"; port: number } | undefined;
now?: () => number;
logger?: { warn: (message: string) => void };
applyTranscriptCommit?: (params: {
identity: WorkerConnectionIdentity;
request: WorkerTranscriptCommitParams;
}) => Promise<WorkerTranscriptCommitApplicationResult>;
};
export type WorkerTranscriptCommitApplicationResult =
| { ok: true; result: WorkerTranscriptCommitResult }
| { ok: false; reason: WorkerTranscriptCommitErrorReason };
export type WorkerTranscriptCommitServiceResult =
| WorkerTranscriptCommitApplicationResult
| { ok: false; closeReason: WorkerProtocolCloseReason };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -946,6 +963,47 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
return { checkedAtMs, credentialHash, grant };
};
const commitTranscript = (
identity: WorkerConnectionIdentity,
request: WorkerTranscriptCommitParams,
): Promise<WorkerTranscriptCommitServiceResult> =>
withLock(identity.environmentId, async () => {
if (stopping) {
return { ok: false, closeReason: "environment-unavailable" };
}
const credential = store.getCredential(identity.environmentId);
if (!credential || !safeEqualSecret(credential.credentialHash, identity.credentialHash)) {
return { ok: false, closeReason: "credential-replaced" };
}
if (now() >= credential.expiresAtMs) {
return { ok: false, closeReason: "credential-expired" };
}
const environment = store.get(identity.environmentId);
if (!environment || environment.destroyRequestedAtMs !== null) {
return { ok: false, closeReason: "environment-unavailable" };
}
if (
request.runEpoch !== identity.ownerEpoch ||
request.runEpoch !== credential.ownerEpoch ||
request.runEpoch !== environment.ownerEpoch
) {
return { ok: false, reason: "epoch-mismatch" };
}
if (
environment.state !== "attached" ||
!identity.sessionId ||
credential.sessionId !== identity.sessionId ||
environment.attachedSessionIds.length !== 1 ||
environment.attachedSessionIds[0] !== identity.sessionId
) {
return { ok: false, reason: "session-not-attached" };
}
if (!options.applyTranscriptCommit) {
return { ok: false, closeReason: "gateway-unavailable" };
}
return await options.applyTranscriptCommit({ identity, request });
});
return {
list: () => store.list().map(project),
get: (environmentId: string) => {
@@ -983,6 +1041,7 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService
stopping
? ("environment-unavailable" as const)
: validateWorkerConnectionIdentity({ store, identity, nowMs: now() }),
commitTranscript,
attachSession,
takeMintedCredential: (binding: WorkerCredentialBinding) =>
readPendingCredential(binding)?.grant,
@@ -281,6 +281,51 @@ describe("worker environment store", () => {
).toThrow("owner epoch changed");
});
it("allocates globally distinct owner epochs when a session moves environments", () => {
const makeReady = (environmentId: string, leaseId: string) => {
const bootstrapping = seedBootstrapping(environmentId, leaseId);
return store.transition({
environmentId,
from: bootstrapping.state,
to: "ready",
patch: readyPatch(),
});
};
const firstReady = makeReady("worker-owner-a", "lease-owner-a");
const first = store.transition({
environmentId: firstReady.environmentId,
from: firstReady.state,
to: "attached",
patch: attachedPatch("shared-session", firstReady.environmentId),
});
store.transition({
environmentId: first.environmentId,
from: first.state,
to: "idle",
});
database.db
.prepare(
`INSERT INTO worker_transcript_commit_heads (
session_id, run_epoch, environment_id, next_seq, updated_at_ms
) VALUES (?, ?, ?, 1, ?)`,
)
.run("shared-session", first.ownerEpoch, first.environmentId, nowMs);
database.db
.prepare("DELETE FROM worker_environments WHERE environment_id = ?")
.run(first.environmentId);
const secondReady = makeReady("worker-owner-b", "lease-owner-b");
const second = store.transition({
environmentId: secondReady.environmentId,
from: secondReady.state,
to: "attached",
patch: attachedPatch("shared-session", secondReady.environmentId),
});
expect(first.ownerEpoch).toBe(2);
expect(second.ownerEpoch).toBeGreaterThan(first.ownerEpoch);
});
it("rejects illegal, stale, and lease-incomplete transitions", () => {
createIntent();
expect(() =>
+26 -6
View File
@@ -63,7 +63,10 @@ export type WorkerEnvironmentTransitionPatch = {
lastError?: string | null;
credential?: CredentialInput;
};
type WorkerDb = Pick<StateDatabase, "worker_environment_credentials" | "worker_environments">;
type WorkerDb = Pick<
StateDatabase,
"worker_environment_credentials" | "worker_environments" | "worker_transcript_commit_heads"
>;
type Row = Selectable<WorkerEnvironments>;
type RowUpdate = Updateable<WorkerEnvironments>;
type CredentialRow = Selectable<WorkerEnvironmentCredentials>;
@@ -285,6 +288,25 @@ function nextOwnerEpoch(ownerEpoch: number): number {
}
return next;
}
function nextGlobalOwnerEpoch(db: DatabaseSync): number {
// Transcript commit identity is (session, epoch, seq), so an ownership
// generation may never be reused when a session moves between environments.
const latestEnvironment = executeSqliteQueryTakeFirstSync(
db,
query(db)
.selectFrom("worker_environments")
.select(({ fn }) => fn.max<number>("owner_epoch").as("owner_epoch")),
);
const latestTranscriptCommit = executeSqliteQueryTakeFirstSync(
db,
query(db)
.selectFrom("worker_transcript_commit_heads")
.select(({ fn }) => fn.max<number>("run_epoch").as("run_epoch")),
);
return nextOwnerEpoch(
Math.max(latestEnvironment?.owner_epoch ?? 0, latestTranscriptCommit?.run_epoch ?? 0),
);
}
function fromRow(row: Row): WorkerEnvironmentRecord {
const record = {
environmentId: row.environment_id,
@@ -684,11 +706,9 @@ export function createWorkerEnvironmentStore(
to === "orphaned");
const ownerEpoch = acceptsBootstrapReceipt
? Math.max(1, current.ownerEpoch)
: acceptsAttachedCredential
? nextOwnerEpoch(current.ownerEpoch)
: ownerEndingTransition
? nextOwnerEpoch(current.ownerEpoch)
: current.ownerEpoch;
: acceptsAttachedCredential || ownerEndingTransition
? nextGlobalOwnerEpoch(db)
: current.ownerEpoch;
const record = update(db, environmentId, from, {
lease_id: leaseId,
ssh_host: sshEndpoint?.host ?? null,
@@ -0,0 +1,128 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import {
createWorkerTranscriptCommitStore,
type WorkerTranscriptCommitInput,
type WorkerTranscriptCommitOutcome,
type WorkerTranscriptCommitStore,
} from "./transcript-commit-store.js";
const SUCCESS_OUTCOME: WorkerTranscriptCommitOutcome = {
ok: true,
result: { entryIds: ["entry-a", "entry-b"], newLeafId: "entry-b" },
};
const ERROR_OUTCOME: WorkerTranscriptCommitOutcome = {
ok: false,
reason: "stale-base-leaf",
};
const BASE_INPUT: WorkerTranscriptCommitInput = {
environmentId: "worker-a",
sessionId: "session-a",
runEpoch: 4,
seq: 1,
requestHash: "a".repeat(64),
};
describe("worker transcript commit store", () => {
let root: string;
let nowMs: number;
let store: WorkerTranscriptCommitStore;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "openclaw-worker-commit-"));
nowMs = 1_000;
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
store = createWorkerTranscriptCommitStore({ database, now: () => nowMs });
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
await fs.rm(root, { recursive: true, force: true });
});
it("recovers pending work and replays a terminal result across reopen", () => {
expect(store.begin(BASE_INPUT)).toEqual({ kind: "claimed" });
expect(store.begin(BASE_INPUT)).toEqual({ kind: "recover" });
nowMs = 1_010;
expect(store.complete({ ...BASE_INPUT, outcome: SUCCESS_OUTCOME })).toEqual(SUCCESS_OUTCOME);
expect(store.begin(BASE_INPUT)).toEqual({ kind: "replay", outcome: SUCCESS_OUTCOME });
closeOpenClawStateDatabaseForTest();
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
store = createWorkerTranscriptCommitStore({ database, now: () => nowMs });
expect(store.begin(BASE_INPUT)).toEqual({ kind: "replay", outcome: SUCCESS_OUTCOME });
});
it("rejects a tuple replayed with a different payload or environment", () => {
expect(store.begin(BASE_INPUT)).toEqual({ kind: "claimed" });
expect(store.begin({ ...BASE_INPUT, requestHash: "b".repeat(64) })).toEqual({
kind: "rejected",
reason: "conflict",
});
store.complete({ ...BASE_INPUT, outcome: SUCCESS_OUTCOME });
expect(store.begin({ ...BASE_INPUT, requestHash: "b".repeat(64) })).toEqual({
kind: "rejected",
reason: "conflict",
});
expect(store.begin({ ...BASE_INPUT, environmentId: "worker-b" })).toEqual({
kind: "rejected",
reason: "conflict",
});
});
it("advances one ordered sequence only after terminal completion", () => {
const second = { ...BASE_INPUT, seq: 2, requestHash: "b".repeat(64) };
const third = { ...BASE_INPUT, seq: 3, requestHash: "c".repeat(64) };
expect(store.begin(second)).toEqual({
kind: "rejected",
reason: "out-of-order",
expectedSeq: 1,
});
expect(store.begin(BASE_INPUT)).toEqual({ kind: "claimed" });
expect(store.begin(second)).toEqual({
kind: "rejected",
reason: "out-of-order",
expectedSeq: 1,
});
store.complete({ ...BASE_INPUT, outcome: SUCCESS_OUTCOME });
expect(store.begin(third)).toEqual({
kind: "rejected",
reason: "out-of-order",
expectedSeq: 2,
});
expect(store.begin(second)).toEqual({ kind: "claimed" });
expect(store.complete({ ...second, outcome: ERROR_OUTCOME })).toEqual(ERROR_OUTCOME);
expect(store.begin(second)).toEqual({ kind: "replay", outcome: ERROR_OUTCOME });
expect(store.begin(third)).toEqual({ kind: "claimed" });
});
it("keeps the first cached terminal outcome", () => {
expect(() => store.complete({ ...BASE_INPUT, outcome: SUCCESS_OUTCOME })).toThrow(
"must begin before terminal completion",
);
store.begin(BASE_INPUT);
expect(store.complete({ ...BASE_INPUT, outcome: SUCCESS_OUTCOME })).toEqual(SUCCESS_OUTCOME);
expect(store.complete({ ...BASE_INPUT, outcome: ERROR_OUTCOME })).toEqual(SUCCESS_OUTCOME);
});
it("starts an independent sequence for a later owner epoch", () => {
expect(store.begin(BASE_INPUT)).toEqual({ kind: "claimed" });
store.complete({ ...BASE_INPUT, outcome: SUCCESS_OUTCOME });
const replacement = {
...BASE_INPUT,
environmentId: "worker-b",
runEpoch: BASE_INPUT.runEpoch + 1,
};
expect(store.begin(replacement)).toEqual({ kind: "claimed" });
expect(store.complete({ ...replacement, outcome: SUCCESS_OUTCOME })).toEqual(SUCCESS_OUTCOME);
});
});
@@ -0,0 +1,323 @@
import type { DatabaseSync } from "node:sqlite";
import type { Insertable, Selectable } from "kysely";
import type {
WorkerTranscriptCommitErrorReason,
WorkerTranscriptCommitResult,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import type {
DB as StateDatabase,
WorkerTranscriptCommitHeads,
WorkerTranscriptCommits,
} from "../../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
type TranscriptCommitDb = Pick<
StateDatabase,
"worker_transcript_commit_heads" | "worker_transcript_commits"
>;
type HeadRow = Selectable<WorkerTranscriptCommitHeads>;
type HeadInsert = Insertable<WorkerTranscriptCommitHeads>;
type CommitRow = Selectable<WorkerTranscriptCommits>;
type CommitInsert = Insertable<WorkerTranscriptCommits>;
export type WorkerTranscriptCommitInput = {
environmentId: string;
sessionId: string;
runEpoch: number;
seq: number;
requestHash: string;
};
export type WorkerTranscriptCommitOutcome =
| { ok: true; result: WorkerTranscriptCommitResult }
| { ok: false; reason: WorkerTranscriptCommitErrorReason };
export type WorkerTranscriptCommitBeginResult =
| { kind: "claimed" }
| { kind: "recover" }
| { kind: "replay"; outcome: WorkerTranscriptCommitOutcome }
| { kind: "rejected"; reason: "conflict" }
| { kind: "rejected"; reason: "out-of-order"; expectedSeq: number };
type NormalizedCommitInput = WorkerTranscriptCommitInput & { nowMs: number };
type ExistingCommitResult = Extract<
WorkerTranscriptCommitBeginResult,
{ kind: "recover" | "replay" | "rejected" }
>;
const REQUEST_HASH_PATTERN = /^[a-f0-9]{64}$/u;
function required(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Worker transcript commit ${field} must be a non-empty string`);
}
return value.trim();
}
function nonNegativeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`Worker transcript commit ${field} must be a non-negative integer`);
}
return value;
}
function positiveInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
throw new Error(`Worker transcript commit ${field} must be a positive integer`);
}
return value;
}
function normalizeRequestHash(value: unknown): string {
if (typeof value !== "string" || !REQUEST_HASH_PATTERN.test(value)) {
throw new Error("Worker transcript commit request hash must be lowercase SHA-256 hex");
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every((entry: unknown) => typeof entry === "string" && entry.length > 0)
);
}
function isCommitResult(value: unknown): value is WorkerTranscriptCommitResult {
if (!isRecord(value) || !isNonEmptyStringArray(value.entryIds)) {
return false;
}
return typeof value.newLeafId === "string" && value.newLeafId.length > 0;
}
function isCommitErrorReason(value: unknown): value is WorkerTranscriptCommitErrorReason {
return (
value === "stale-base-leaf" ||
value === "epoch-mismatch" ||
value === "invalid-batch" ||
value === "session-not-attached"
);
}
function parseOutcomeJson(value: string): WorkerTranscriptCommitOutcome {
let parsed: unknown;
try {
parsed = JSON.parse(value) as unknown;
} catch (error) {
throw new Error("Worker transcript commit cached outcome is invalid", { cause: error });
}
if (!isRecord(parsed)) {
throw new Error("Worker transcript commit cached outcome is invalid");
}
if (parsed.ok === true && isCommitResult(parsed.result)) {
return { ok: true, result: parsed.result };
}
if (parsed.ok === false && isCommitErrorReason(parsed.reason)) {
return { ok: false, reason: parsed.reason };
}
throw new Error("Worker transcript commit cached outcome is invalid");
}
function serializeOutcome(outcome: WorkerTranscriptCommitOutcome): string {
const serialized = JSON.stringify(outcome);
if (!serialized) {
throw new Error("Worker transcript commit outcome is not serializable");
}
return serialized;
}
function normalizeInput(input: WorkerTranscriptCommitInput, nowMs: number): NormalizedCommitInput {
return {
environmentId: required(input.environmentId, "environment id"),
sessionId: required(input.sessionId, "session id"),
runEpoch: nonNegativeInteger(input.runEpoch, "run epoch"),
seq: positiveInteger(input.seq, "sequence"),
requestHash: normalizeRequestHash(input.requestHash),
nowMs: nonNegativeInteger(nowMs, "timestamp"),
};
}
const query = (db: DatabaseSync) => getNodeSqliteKysely<TranscriptCommitDb>(db);
function findHead(db: DatabaseSync, input: NormalizedCommitInput): HeadRow | undefined {
return executeSqliteQueryTakeFirstSync(
db,
query(db)
.selectFrom("worker_transcript_commit_heads")
.selectAll()
.where("session_id", "=", input.sessionId)
.where("run_epoch", "=", input.runEpoch),
);
}
function findCommit(db: DatabaseSync, input: NormalizedCommitInput): CommitRow | undefined {
return executeSqliteQueryTakeFirstSync(
db,
query(db)
.selectFrom("worker_transcript_commits")
.selectAll()
.where("session_id", "=", input.sessionId)
.where("run_epoch", "=", input.runEpoch)
.where("seq", "=", input.seq),
);
}
function classifyExistingCommit(params: {
head: HeadRow | undefined;
commit: CommitRow | undefined;
input: NormalizedCommitInput;
}): ExistingCommitResult | undefined {
if (!params.commit) {
return undefined;
}
if (!params.head) {
throw new Error("Worker transcript commit row has no sequence head");
}
if (
params.head.environment_id !== params.input.environmentId ||
params.commit.request_hash !== params.input.requestHash
) {
return { kind: "rejected", reason: "conflict" };
}
if (params.commit.state === "pending") {
return { kind: "recover" };
}
if (params.commit.state === "terminal" && params.commit.result_json !== null) {
return { kind: "replay", outcome: parseOutcomeJson(params.commit.result_json) };
}
throw new Error("Worker transcript commit row has invalid terminal state");
}
function insertHead(db: DatabaseSync, input: NormalizedCommitInput): void {
const head: HeadInsert = {
session_id: input.sessionId,
run_epoch: input.runEpoch,
environment_id: input.environmentId,
next_seq: 1,
updated_at_ms: input.nowMs,
};
executeSqliteQuerySync(db, query(db).insertInto("worker_transcript_commit_heads").values(head));
}
function insertPendingCommit(db: DatabaseSync, input: NormalizedCommitInput): void {
const commit: CommitInsert = {
session_id: input.sessionId,
run_epoch: input.runEpoch,
seq: input.seq,
request_hash: input.requestHash,
state: "pending",
result_json: null,
created_at_ms: input.nowMs,
updated_at_ms: input.nowMs,
};
executeSqliteQuerySync(db, query(db).insertInto("worker_transcript_commits").values(commit));
}
export function createWorkerTranscriptCommitStore(
options: { database?: OpenClawStateDatabase; now?: () => number } = {},
) {
const path = (options.database ?? openOpenClawStateDatabase()).path;
const now = options.now ?? Date.now;
const write = <T>(operation: (db: DatabaseSync) => T): T =>
runOpenClawStateWriteTransaction(({ db }) => operation(db), { path });
const begin = (rawInput: WorkerTranscriptCommitInput): WorkerTranscriptCommitBeginResult => {
const input = normalizeInput(rawInput, now());
return write<WorkerTranscriptCommitBeginResult>((db) => {
const head = findHead(db, input);
const existing = classifyExistingCommit({ head, commit: findCommit(db, input), input });
if (existing) {
return existing;
}
if (head && head.environment_id !== input.environmentId) {
return { kind: "rejected", reason: "conflict" };
}
const expectedSeq = head?.next_seq ?? 1;
if (input.seq !== expectedSeq) {
return { kind: "rejected", reason: "out-of-order", expectedSeq };
}
if (!head) {
insertHead(db, input);
}
insertPendingCommit(db, input);
return { kind: "claimed" };
});
};
const complete = (
rawInput: WorkerTranscriptCommitInput & { outcome: WorkerTranscriptCommitOutcome },
): WorkerTranscriptCommitOutcome => {
const input = normalizeInput(rawInput, now());
const resultJson = serializeOutcome(rawInput.outcome);
return write<WorkerTranscriptCommitOutcome>((db) => {
const head = findHead(db, input);
const commit = findCommit(db, input);
const existing = classifyExistingCommit({ head, commit, input });
if (!existing) {
throw new Error("Worker transcript commit must begin before terminal completion");
}
if (existing.kind === "rejected") {
throw new Error(
`Worker transcript commit terminal completion rejected: ${existing.reason}`,
);
}
if (existing.kind === "replay") {
return existing.outcome;
}
if (!head) {
throw new Error("Worker transcript commit row has no sequence head");
}
if (head.next_seq !== input.seq) {
throw new Error(
`Worker transcript commit terminal completion expected sequence ${head.next_seq}`,
);
}
const commitUpdate = executeSqliteQuerySync(
db,
query(db)
.updateTable("worker_transcript_commits")
.set({ state: "terminal", result_json: resultJson, updated_at_ms: input.nowMs })
.where("session_id", "=", input.sessionId)
.where("run_epoch", "=", input.runEpoch)
.where("seq", "=", input.seq)
.where("request_hash", "=", input.requestHash)
.where("state", "=", "pending"),
);
if (commitUpdate.numAffectedRows !== 1n) {
throw new Error("Worker transcript commit changed during terminal completion");
}
const headUpdate = executeSqliteQuerySync(
db,
query(db)
.updateTable("worker_transcript_commit_heads")
.set({ next_seq: input.seq + 1, updated_at_ms: input.nowMs })
.where("session_id", "=", input.sessionId)
.where("run_epoch", "=", input.runEpoch)
.where("environment_id", "=", input.environmentId)
.where("next_seq", "=", input.seq),
);
if (headUpdate.numAffectedRows !== 1n) {
throw new Error("Worker transcript commit sequence changed during terminal completion");
}
return rawInput.outcome;
});
};
return { begin, complete };
}
export type WorkerTranscriptCommitStore = ReturnType<typeof createWorkerTranscriptCommitStore>;
@@ -0,0 +1,716 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
WorkerTranscriptCommitParams,
WorkerTranscriptMessage,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import { SessionManager } from "../../agents/sessions/session-manager.js";
import {
loadSessionEntry,
loadTranscriptEvents,
resolveSessionTranscriptRuntimeTarget,
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import type { WorkerConnectionIdentity } from "./connection-identity.js";
import {
createWorkerTranscriptCommitStore,
type WorkerTranscriptCommitStore,
} from "./transcript-commit-store.js";
import {
createWorkerTranscriptCommitter,
type WorkerTranscriptCommitter,
} from "./transcript-commit.js";
const SESSION_ID = "session-worker-transcript";
const SESSION_KEY = "agent:main:worker-transcript";
const RUN_EPOCH = 7;
const IDENTITY: WorkerConnectionIdentity = {
environmentId: "environment-a",
credentialHash: ["credential", "hash", "a"].join("-"),
bundleHash: "b".repeat(64),
sessionId: SESSION_ID,
ownerEpoch: RUN_EPOCH,
rpcSetVersion: 1,
protocolFeatures: ["worker-transcript-commit-v1"],
credentialExpiresAtMs: 10_000,
};
const ZERO_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
function createTurnMessages(userText = "Inspect the workspace"): WorkerTranscriptMessage[] {
return [
{
role: "user",
content: [{ type: "text", text: userText }],
timestamp: 100,
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "I will inspect it." },
{
type: "toolCall",
id: "call-read-1",
name: "read",
arguments: { path: "README.md" },
},
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.5",
diagnostics: [
{
type: "provider-warning",
timestamp: 201,
error: { name: "", message: "diagnostic", stack: "", code: 0 },
details: { empty: "", enabled: false },
},
],
usage: ZERO_USAGE,
stopReason: "toolUse",
timestamp: 200,
},
{
role: "toolResult",
toolCallId: "call-read-1",
toolName: "read",
content: [{ type: "text", text: "Workspace ready." }],
isError: false,
timestamp: 300,
},
];
}
function createRequest(
params: {
baseLeafId?: string | null;
messages?: WorkerTranscriptMessage[];
seq?: number;
} = {},
): WorkerTranscriptCommitParams {
return {
runEpoch: RUN_EPOCH,
seq: params.seq ?? 1,
baseLeafId: params.baseLeafId ?? null,
messages: params.messages ?? createTurnMessages(),
};
}
function messageIdempotencyKey(seq: number, index: number): string {
const digest = createHash("sha256")
.update([SESSION_ID, RUN_EPOCH, seq, index].join("\0"))
.digest("base64url");
return `worker-commit-${digest}`;
}
function requireAppendableWorkerMessage(
message: unknown,
): Parameters<SessionManager["appendMessage"]>[0] {
if (!message || typeof message !== "object" || Array.isArray(message)) {
throw new Error("expected committed worker message");
}
const role = (message as { role?: unknown }).role;
if (role !== "assistant" && role !== "toolResult" && role !== "user") {
throw new Error("expected committed worker message");
}
return message as Parameters<SessionManager["appendMessage"]>[0];
}
describe("worker transcript commit application", () => {
let root: string;
let sessionsDir: string;
let storePath: string;
let sessionFile: string;
let cfg: OpenClawConfig;
let committer: WorkerTranscriptCommitter;
let ledgerStore: WorkerTranscriptCommitStore;
let unsubscribe: (() => void) | undefined;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "openclaw-worker-turn-"));
sessionsDir = path.join(root, "agents", "main", "sessions");
storePath = path.join(sessionsDir, "sessions.json");
cfg = {
agents: { list: [{ id: "main", default: true }] },
session: {
mainKey: "main",
store: path.join(root, "agents", "{agentId}", "sessions", "sessions.json"),
},
};
await upsertSessionEntry(
{ agentId: "main", sessionKey: SESSION_KEY, storePath },
{
sessionId: SESSION_ID,
updatedAt: 10,
},
);
sessionFile = (
await resolveSessionTranscriptRuntimeTarget({
agentId: "main",
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
storePath,
})
).sessionFile;
const database = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: path.join(root, "state") },
});
ledgerStore = createWorkerTranscriptCommitStore({ database });
committer = createWorkerTranscriptCommitter({
getConfig: () => cfg,
store: ledgerStore,
});
});
afterEach(async () => {
unsubscribe?.();
closeOpenClawStateDatabaseForTest();
await fs.rm(root, { recursive: true, force: true });
});
it("commits semantic turns as a generated parent-linked transcript and publishes normally", async () => {
const updates: Parameters<Parameters<typeof onSessionTranscriptUpdate>[0]>[0][] = [];
unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update));
const outcome = await committer.commit({ identity: IDENTITY, request: createRequest() });
expect(outcome.ok).toBe(true);
if (!outcome.ok) {
throw new Error(`expected transcript commit success, received ${outcome.reason}`);
}
const { entryIds, newLeafId } = outcome.result;
expect(entryIds).toHaveLength(3);
expect(new Set(entryIds).size).toBe(3);
expect(newLeafId).toBe(entryIds[2]);
const reopened = SessionManager.open(sessionFile);
expect(reopened.getLeafId()).toBe(newLeafId);
expect(reopened.getEntries()).toEqual([
expect.objectContaining({
type: "message",
id: entryIds[0],
parentId: null,
message: expect.objectContaining({
role: "user",
content: [{ type: "text", text: "Inspect the workspace" }],
}),
}),
expect.objectContaining({
type: "message",
id: entryIds[1],
parentId: entryIds[0],
message: expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([
expect.objectContaining({ type: "toolCall", id: "call-read-1" }),
]),
diagnostics: [
{
type: "provider-warning",
timestamp: 201,
error: { name: "", message: "diagnostic", stack: "", code: 0 },
details: { empty: "", enabled: false },
},
],
}),
}),
expect.objectContaining({
type: "message",
id: entryIds[2],
parentId: entryIds[1],
message: expect.objectContaining({
role: "toolResult",
toolCallId: "call-read-1",
}),
}),
]);
const readEvents = await loadTranscriptEvents({
agentId: "main",
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
storePath,
});
expect(
readEvents
.filter((event): event is { type: "message"; id: string } =>
Boolean(
event &&
typeof event === "object" &&
!Array.isArray(event) &&
(event as { type?: unknown }).type === "message" &&
typeof (event as { id?: unknown }).id === "string",
),
)
.map((event) => event.id),
).toEqual(entryIds);
const persistedEntry = loadSessionEntry({
agentId: "main",
sessionKey: SESSION_KEY,
storePath,
});
expect(persistedEntry).toMatchObject({ sessionId: SESSION_ID });
expect(persistedEntry?.sessionFile).toBe(
(
await resolveSessionTranscriptRuntimeTarget({
agentId: "main",
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
storePath,
})
).sessionFile,
);
expect(updates).toEqual(
entryIds.map((entryId, index) =>
expect.objectContaining({
agentId: "main",
message: expect.objectContaining({ role: createTurnMessages()[index]?.role }),
messageId: entryId,
messageSeq: index + 1,
sessionKey: SESSION_KEY,
sessionId: SESSION_ID,
target: {
agentId: "main",
sessionId: SESSION_ID,
sessionKey: SESSION_KEY,
},
}),
),
);
});
it("durably materializes a user-only commit", async () => {
const outcome = await committer.commit({
identity: IDENTITY,
request: createRequest({
messages: [
{
role: "user",
content: [{ type: "text", text: "Persist before inference" }],
timestamp: 100,
},
],
}),
});
expect(outcome.ok).toBe(true);
if (!outcome.ok) {
throw new Error(`expected user-only transcript commit, received ${outcome.reason}`);
}
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries()).toEqual([
expect.objectContaining({
id: outcome.result.newLeafId,
parentId: null,
message: expect.objectContaining({ role: "user" }),
}),
]);
expect(reopened.getLeafId()).toBe(outcome.result.newLeafId);
});
it("rejects a stale base leaf without appending", async () => {
const first = await committer.commit({ identity: IDENTITY, request: createRequest() });
if (!first.ok) {
throw new Error(`expected initial transcript commit success, received ${first.reason}`);
}
const stale = await committer.commit({
identity: IDENTITY,
request: createRequest({
baseLeafId: null,
messages: [
{
role: "user",
content: [{ type: "text", text: "Stale turn" }],
timestamp: 400,
},
],
seq: 2,
}),
});
expect(stale).toEqual({ ok: false, reason: "stale-base-leaf" });
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries()).toHaveLength(3);
expect(reopened.getLeafId()).toBe(first.result.newLeafId);
});
it("replays the same tuple without duplicates and rejects a changed payload", async () => {
const request = createRequest();
const first = await committer.commit({ identity: IDENTITY, request });
const replay = await committer.commit({
identity: IDENTITY,
request: structuredClone(request),
});
const changed = await committer.commit({
identity: IDENTITY,
request: createRequest({ messages: createTurnMessages("Changed payload") }),
});
expect(first.ok).toBe(true);
expect(replay).toEqual(first);
expect(changed).toEqual({ ok: false, reason: "invalid-batch" });
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries()).toHaveLength(3);
if (first.ok) {
expect(reopened.getLeafId()).toBe(first.result.newLeafId);
}
});
it("recovers an interrupted terminal write after later transcript activity", async () => {
let interruptCompletion = true;
const interruptedStore: WorkerTranscriptCommitStore = {
begin: ledgerStore.begin,
complete: (input) => {
if (interruptCompletion) {
interruptCompletion = false;
throw new Error("simulated commit-result interruption");
}
return ledgerStore.complete(input);
},
};
const interruptedCommitter = createWorkerTranscriptCommitter({
getConfig: () => cfg,
store: interruptedStore,
});
const request = createRequest();
await expect(interruptedCommitter.commit({ identity: IDENTITY, request })).rejects.toThrow(
"simulated commit-result interruption",
);
const afterInterruption = SessionManager.open(sessionFile);
const committedEntryIds = afterInterruption.getEntries().map((entry) => entry.id);
expect(committedEntryIds).toHaveLength(request.messages.length);
const laterLeafId = afterInterruption.appendMessage({
role: "user",
content: [{ type: "text", text: "Later local activity" }],
timestamp: 400,
});
const replay = await committer.commit({ identity: IDENTITY, request });
expect(replay).toEqual({
ok: true,
result: {
entryIds: committedEntryIds,
newLeafId: committedEntryIds.at(-1),
},
});
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries()).toHaveLength(request.messages.length + 1);
expect(reopened.getLeafId()).toBe(laterLeafId);
});
it("replays an interrupted terminal write after its branch is abandoned", async () => {
cfg = { ...cfg, logging: { redactSensitive: "tools" } };
const initialManager = SessionManager.open(sessionFile);
const baseLeafId = initialManager.appendMessage({
role: "user",
content: [{ type: "text", text: "Local base" }],
timestamp: 50,
});
let interruptCompletion = true;
const interruptedStore: WorkerTranscriptCommitStore = {
begin: ledgerStore.begin,
complete: (input) => {
if (interruptCompletion) {
interruptCompletion = false;
throw new Error("simulated off-branch terminal interruption");
}
return ledgerStore.complete(input);
},
};
const interruptedCommitter = createWorkerTranscriptCommitter({
getConfig: () => cfg,
store: interruptedStore,
});
const request = createRequest({
baseLeafId,
messages: createTurnMessages("my key is sk-abcdef1234567890xyz"),
});
await expect(interruptedCommitter.commit({ identity: IDENTITY, request })).rejects.toThrow(
"simulated off-branch terminal interruption",
);
const afterInterruption = SessionManager.open(sessionFile);
const committedEntries = afterInterruption
.getEntries()
.filter((entry) => entry.id !== baseLeafId);
const committedEntryIds = committedEntries.map((entry) => entry.id);
expect(committedEntryIds).toHaveLength(request.messages.length);
expect(JSON.stringify(committedEntries)).not.toContain("sk-abcdef1234567890xyz");
const firstCommitted = committedEntries[0];
if (firstCommitted?.type !== "message") {
throw new Error("expected committed worker message");
}
afterInterruption.branch(baseLeafId);
const duplicatePrefixId = afterInterruption.appendMessage(
requireAppendableWorkerMessage(firstCommitted.message),
{ idempotencyLookup: "caller-checked" },
);
afterInterruption.appendMessage({
role: "user",
content: [{ type: "text", text: "Incomplete duplicate branch" }],
timestamp: 350,
});
afterInterruption.branch(baseLeafId);
const localLeafId = afterInterruption.appendMessage({
role: "user",
content: [{ type: "text", text: "Local branch wins" }],
timestamp: 400,
});
const updates: Parameters<Parameters<typeof onSessionTranscriptUpdate>[0]>[0][] = [];
unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update));
cfg = { ...cfg, logging: { redactSensitive: "off" } };
const replay = await committer.commit({ identity: IDENTITY, request });
expect(replay).toEqual({
ok: true,
result: {
entryIds: committedEntryIds,
newLeafId: committedEntryIds.at(-1),
},
});
const reopened = SessionManager.open(sessionFile);
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([baseLeafId, localLeafId]);
if (!replay.ok) {
throw new Error(`expected interrupted commit replay, received ${replay.reason}`);
}
expect(replay.result.entryIds).not.toContain(duplicatePrefixId);
expect(updates).toEqual([]);
});
it("rejects ambiguous persisted recovery without appending or publishing", async () => {
const initialManager = SessionManager.open(sessionFile);
const baseLeafId = initialManager.appendMessage({
role: "user",
content: [{ type: "text", text: "Local base" }],
timestamp: 50,
});
let interruptCompletion = true;
const interruptedCommitter = createWorkerTranscriptCommitter({
getConfig: () => cfg,
store: {
begin: ledgerStore.begin,
complete: (input) => {
if (interruptCompletion) {
interruptCompletion = false;
throw new Error("simulated ambiguous terminal interruption");
}
return ledgerStore.complete(input);
},
},
});
const request = createRequest({ baseLeafId });
await expect(interruptedCommitter.commit({ identity: IDENTITY, request })).rejects.toThrow(
"simulated ambiguous terminal interruption",
);
const manager = SessionManager.open(sessionFile);
const originalEntries = manager.getEntries().filter((entry) => entry.id !== baseLeafId);
expect(originalEntries).toHaveLength(request.messages.length);
manager.branch(baseLeafId);
for (const entry of originalEntries) {
if (entry.type !== "message") {
throw new Error("expected committed worker message");
}
manager.appendMessage(requireAppendableWorkerMessage(entry.message), {
idempotencyLookup: "caller-checked",
});
}
manager.branch(baseLeafId);
const localLeafId = manager.appendMessage({
role: "user",
content: [{ type: "text", text: "Local branch wins" }],
timestamp: 400,
});
const entryCountBeforeRetry = manager.getEntries().length;
const updates: Parameters<Parameters<typeof onSessionTranscriptUpdate>[0]>[0][] = [];
unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update));
const replay = await committer.commit({ identity: IDENTITY, request });
expect(replay).toEqual({ ok: false, reason: "invalid-batch" });
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries()).toHaveLength(entryCountBeforeRetry);
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([baseLeafId, localLeafId]);
expect(updates).toEqual([]);
});
it("rolls back every transcript row when a batch append is interrupted", async () => {
type AppendMessage = (
this: SessionManager,
...args: Parameters<SessionManager["appendMessage"]>
) => ReturnType<SessionManager["appendMessage"]>;
const appendMessage = Object.getOwnPropertyDescriptor(SessionManager.prototype, "appendMessage")
?.value as AppendMessage | undefined;
if (!appendMessage) {
throw new Error("SessionManager.appendMessage implementation is unavailable");
}
let appendCount = 0;
const appendSpy = vi
.spyOn(SessionManager.prototype, "appendMessage")
.mockImplementation(function (this: SessionManager, message, options) {
const messageId = appendMessage.call(this, message, options);
appendCount += 1;
if (appendCount === 2) {
throw new Error("simulated mid-batch interruption");
}
return messageId;
});
const request = createRequest();
const entryBeforeFailure = loadSessionEntry({
agentId: "main",
sessionKey: SESSION_KEY,
storePath,
});
try {
await expect(committer.commit({ identity: IDENTITY, request })).rejects.toThrow(
"simulated mid-batch interruption",
);
} finally {
appendSpy.mockRestore();
}
expect(SessionManager.open(sessionFile).getEntries()).toEqual([]);
const entryAfterFailure = loadSessionEntry({
agentId: "main",
sessionKey: SESSION_KEY,
storePath,
});
expect(entryAfterFailure).toEqual(entryBeforeFailure);
const manager = SessionManager.open(sessionFile);
const localLeafId = manager.appendMessage({
role: "user",
content: [{ type: "text", text: "Local activity after interruption" }],
timestamp: 400,
});
const retry = await committer.commit({ identity: IDENTITY, request });
expect(retry).toEqual({ ok: false, reason: "stale-base-leaf" });
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries()).toEqual([
expect.objectContaining({
id: localLeafId,
message: expect.objectContaining({ role: "user" }),
}),
]);
});
it("does not reuse an idempotency key from an abandoned transcript branch", async () => {
const first = await committer.commit({ identity: IDENTITY, request: createRequest() });
if (!first.ok) {
throw new Error(`expected initial transcript commit success, received ${first.reason}`);
}
const manager = SessionManager.open(sessionFile);
const abandonedMessage: Parameters<SessionManager["appendMessage"]>[0] & {
idempotencyKey: string;
} = {
role: "user",
content: [{ type: "text", text: "Abandoned worker-shaped row" }],
timestamp: 400,
idempotencyKey: messageIdempotencyKey(2, 0),
};
const abandonedId = manager.appendMessage(abandonedMessage);
manager.branch(first.result.newLeafId);
const activeLeafId = manager.appendMessage({
role: "user",
content: [{ type: "text", text: "Active local row" }],
timestamp: 500,
});
const outcome = await committer.commit({
identity: IDENTITY,
request: createRequest({
baseLeafId: activeLeafId,
messages: [
{
role: "user",
content: [{ type: "text", text: "Fresh worker row" }],
timestamp: 600,
},
],
seq: 2,
}),
});
expect(outcome.ok).toBe(true);
if (!outcome.ok) {
throw new Error(`expected branch-safe transcript commit, received ${outcome.reason}`);
}
expect(outcome.result.newLeafId).not.toBe(abandonedId);
const reopened = SessionManager.open(sessionFile);
expect(reopened.getLeafId()).toBe(outcome.result.newLeafId);
expect(reopened.getEntry(outcome.result.newLeafId)).toMatchObject({
parentId: activeLeafId,
message: expect.objectContaining({ idempotencyKey: messageIdempotencyKey(2, 0) }),
});
});
it("advances the leaf across sequential commits", async () => {
const first = await committer.commit({ identity: IDENTITY, request: createRequest() });
if (!first.ok) {
throw new Error(`expected initial transcript commit success, received ${first.reason}`);
}
const nextMessage: WorkerTranscriptMessage = {
role: "assistant",
content: [{ type: "text", text: "Finished." }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.5",
usage: ZERO_USAGE,
stopReason: "stop",
timestamp: 400,
};
const second = await committer.commit({
identity: IDENTITY,
request: createRequest({
baseLeafId: first.result.newLeafId,
messages: [nextMessage],
seq: 2,
}),
});
expect(second.ok).toBe(true);
if (!second.ok) {
throw new Error(`expected sequential transcript commit success, received ${second.reason}`);
}
expect(second.result.entryIds).toHaveLength(1);
expect(second.result.newLeafId).toBe(second.result.entryIds[0]);
expect(second.result.newLeafId).not.toBe(first.result.newLeafId);
const reopened = SessionManager.open(sessionFile);
expect(reopened.getEntries().at(-1)).toMatchObject({
id: second.result.newLeafId,
parentId: first.result.newLeafId,
message: expect.objectContaining({ role: "assistant" }),
});
expect(reopened.getLeafId()).toBe(second.result.newLeafId);
});
});
@@ -0,0 +1,529 @@
import { createHash } from "node:crypto";
import type {
WorkerTranscriptCommitParams,
WorkerTranscriptMessage,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { AgentMessage } from "../../agents/runtime/index.js";
import { SessionManager } from "../../agents/sessions/session-manager.js";
import { stableStringify } from "../../agents/stable-stringify.js";
import { redactTranscriptMessage } from "../../agents/transcript-redact.js";
import {
loadSessionEntry,
publishTranscriptUpdate,
replaceSessionEntrySync,
type SessionTranscriptWriteScope,
withTranscriptWriteTransaction,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js";
import {
loadCombinedSessionStoreForGateway,
resolveFreshestSessionEntryFromStoreKeys,
resolveGatewaySessionStoreTargetWithStore,
} from "../session-utils.js";
import type { WorkerConnectionIdentity } from "./connection-identity.js";
import {
createWorkerTranscriptCommitStore,
type WorkerTranscriptCommitInput,
type WorkerTranscriptCommitOutcome,
type WorkerTranscriptCommitStore,
} from "./transcript-commit-store.js";
type WorkerTranscriptCommitterOptions = {
getConfig: () => OpenClawConfig;
store?: WorkerTranscriptCommitStore;
};
type ResolvedWorkerTranscriptTarget = Omit<
SessionTranscriptWriteScope,
"sessionId" | "sessionKey"
> & {
sessionEntry: NonNullable<ReturnType<typeof resolveFreshestSessionEntryFromStoreKeys>>;
sessionId: string;
sessionKey: string;
sessionStore: Record<
string,
NonNullable<ReturnType<typeof resolveFreshestSessionEntryFromStoreKeys>>
>;
};
type SemanticAgentMessage = Extract<AgentMessage, { role: "assistant" | "toolResult" | "user" }>;
type CommittedAgentMessage = SemanticAgentMessage & { idempotencyKey: string };
type AppliedTranscriptMessage = {
appended: boolean;
message: CommittedAgentMessage;
messageId: string;
messageSeq?: number;
};
type ApplyTranscriptCommitResult =
| { ok: true; messages: AppliedTranscriptMessage[] }
| { ok: false; reason: "invalid-batch" | "session-not-attached" | "stale-base-leaf" };
type PersistedCommitResolution =
| { kind: "ambiguous" | "missing" }
| { kind: "found"; messages: AppliedTranscriptMessage[] };
function cloneContentPart(
part: WorkerTranscriptMessage["content"][number],
): WorkerTranscriptMessage["content"][number] {
if (part.type === "text") {
return {
type: "text",
text: part.text,
...(part.textSignature ? { textSignature: part.textSignature } : {}),
};
}
if (part.type === "image") {
return { type: "image", data: part.data, mimeType: part.mimeType };
}
if (part.type === "thinking") {
return {
type: "thinking",
thinking: part.thinking,
...(part.thinkingSignature ? { thinkingSignature: part.thinkingSignature } : {}),
...(part.redacted === undefined ? {} : { redacted: part.redacted }),
};
}
return {
type: "toolCall",
id: part.id,
name: part.name,
arguments: structuredClone(part.arguments),
...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
...(part.executionMode ? { executionMode: part.executionMode } : {}),
};
}
function buildCommittedMessage(
message: WorkerTranscriptMessage,
idempotencyKey: string,
): CommittedAgentMessage {
const content = message.content.map((part) => cloneContentPart(part));
if (message.role === "user") {
return {
role: "user",
content,
timestamp: message.timestamp,
idempotencyKey,
} as CommittedAgentMessage;
}
if (message.role === "toolResult") {
return {
role: "toolResult",
toolCallId: message.toolCallId,
toolName: message.toolName,
content,
...(message.details === undefined ? {} : { details: structuredClone(message.details) }),
isError: message.isError,
timestamp: message.timestamp,
idempotencyKey,
} as CommittedAgentMessage;
}
return {
role: "assistant",
content,
api: message.api,
provider: message.provider,
model: message.model,
...(message.responseModel ? { responseModel: message.responseModel } : {}),
...(message.responseId ? { responseId: message.responseId } : {}),
...(message.diagnostics
? {
diagnostics: message.diagnostics.map((diagnostic) => ({
type: diagnostic.type,
timestamp: diagnostic.timestamp,
...(diagnostic.error
? {
error: {
...(diagnostic.error.name === undefined ? {} : { name: diagnostic.error.name }),
message: diagnostic.error.message,
...(diagnostic.error.stack === undefined
? {}
: { stack: diagnostic.error.stack }),
...(diagnostic.error.code === undefined ? {} : { code: diagnostic.error.code }),
},
}
: {}),
...(diagnostic.details ? { details: structuredClone(diagnostic.details) } : {}),
})),
}
: {}),
usage: {
input: message.usage.input,
output: message.usage.output,
cacheRead: message.usage.cacheRead,
cacheWrite: message.usage.cacheWrite,
...(message.usage.contextUsage
? { contextUsage: structuredClone(message.usage.contextUsage) }
: {}),
totalTokens: message.usage.totalTokens,
cost: {
input: message.usage.cost.input,
output: message.usage.cost.output,
cacheRead: message.usage.cost.cacheRead,
cacheWrite: message.usage.cost.cacheWrite,
total: message.usage.cost.total,
...(message.usage.cost.totalOrigin ? { totalOrigin: message.usage.cost.totalOrigin } : {}),
},
},
stopReason: message.stopReason,
...(message.errorMessage === undefined ? {} : { errorMessage: message.errorMessage }),
...(message.errorCode === undefined ? {} : { errorCode: message.errorCode }),
...(message.errorType === undefined ? {} : { errorType: message.errorType }),
...(message.errorBody === undefined ? {} : { errorBody: message.errorBody }),
timestamp: message.timestamp,
idempotencyKey,
} as CommittedAgentMessage;
}
function requestHash(request: WorkerTranscriptCommitParams): string {
return createHash("sha256")
.update(
stableStringify({
baseLeafId: request.baseLeafId,
messages: request.messages,
}),
)
.digest("hex");
}
function messageIdempotencyKey(params: {
sessionId: string;
runEpoch: number;
seq: number;
index: number;
}): string {
const digest = createHash("sha256")
.update([params.sessionId, params.runEpoch, params.seq, params.index].join("\0"))
.digest("base64url");
return `worker-commit-${digest}`;
}
function resolveWorkerTranscriptTarget(
cfg: OpenClawConfig,
sessionId: string,
): ResolvedWorkerTranscriptTarget | undefined {
const { store } = loadCombinedSessionStoreForGateway(cfg);
const matches = Object.entries(store).filter(([, entry]) => entry.sessionId === sessionId);
const selection = resolveSessionIdMatchSelection(matches, sessionId);
if (selection.kind !== "selected") {
return undefined;
}
const target = resolveGatewaySessionStoreTargetWithStore({
cfg,
key: selection.sessionKey,
clone: false,
});
const entry = resolveFreshestSessionEntryFromStoreKeys(target.store, target.storeKeys);
if (!entry || entry.sessionId !== sessionId) {
return undefined;
}
return {
agentId: target.agentId,
sessionEntry: entry,
sessionId,
sessionKey: target.canonicalKey,
sessionStore: target.store,
storePath: target.storePath,
};
}
function readMessageIdempotencyKey(message: unknown): string | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const value = (message as { idempotencyKey?: unknown }).idempotencyKey;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isCommittedAgentMessage(message: unknown): message is CommittedAgentMessage {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return false;
}
const role = (message as { role?: unknown }).role;
return (
(role === "user" || role === "assistant" || role === "toolResult") &&
readMessageIdempotencyKey(message) !== undefined
);
}
function resolveActiveCommitPrefix(params: {
baseLeafId: string | null;
manager: SessionManager;
messages: readonly CommittedAgentMessage[];
}):
| {
activeVisibleEntryCount: number;
ok: true;
recoveredMessages: AppliedTranscriptMessage[];
}
| { ok: false } {
const activeBranch = params.manager.getBranch();
const activeVisibleEntryCount = activeBranch.filter(
(entry) => entry.type === "message" || entry.type === "compaction",
).length;
if (params.manager.getLeafId() === params.baseLeafId) {
return { activeVisibleEntryCount, ok: true, recoveredMessages: [] };
}
const baseIndex =
params.baseLeafId === null
? -1
: activeBranch.findIndex((entry) => entry.id === params.baseLeafId);
if (params.baseLeafId !== null && baseIndex < 0) {
return { ok: false };
}
const activeSuffix = activeBranch.slice(baseIndex + 1);
if (activeSuffix.length === 0) {
return { ok: false };
}
const recoveredMessages: AppliedTranscriptMessage[] = [];
for (const [index, entry] of activeSuffix.slice(0, params.messages.length).entries()) {
const expectedKey = readMessageIdempotencyKey(params.messages[index]);
if (
entry.type !== "message" ||
!expectedKey ||
!isCommittedAgentMessage(entry.message) ||
readMessageIdempotencyKey(entry.message) !== expectedKey
) {
return { ok: false };
}
recoveredMessages.push({
appended: false,
message: entry.message,
messageId: entry.id,
});
}
return { activeVisibleEntryCount, ok: true, recoveredMessages };
}
function resolvePersistedCommitAcrossDag(params: {
baseLeafId: string | null;
manager: SessionManager;
messages: readonly CommittedAgentMessage[];
}): PersistedCommitResolution {
const childrenByParent = new Map<string | null, ReturnType<SessionManager["getEntries"]>>();
for (const entry of params.manager.getEntries()) {
const children = childrenByParent.get(entry.parentId) ?? [];
children.push(entry);
childrenByParent.set(entry.parentId, children);
}
const completedPaths: AppliedTranscriptMessage[][] = [];
const visit = (
parentId: string | null,
messageIndex: number,
path: AppliedTranscriptMessage[],
): void => {
if (completedPaths.length > 1) {
return;
}
if (messageIndex === params.messages.length) {
completedPaths.push(path);
return;
}
const expectedKey = readMessageIdempotencyKey(params.messages[messageIndex]);
if (!expectedKey) {
return;
}
for (const entry of childrenByParent.get(parentId) ?? []) {
if (
entry.type !== "message" ||
!isCommittedAgentMessage(entry.message) ||
readMessageIdempotencyKey(entry.message) !== expectedKey
) {
continue;
}
visit(entry.id, messageIndex + 1, [
...path,
{ appended: false, message: entry.message, messageId: entry.id },
]);
}
};
// The pending ledger binds the request hash while each deterministic key
// binds tuple + index. Do not compare re-redacted content across restarts.
visit(params.baseLeafId, 0, []);
if (completedPaths.length > 1) {
return { kind: "ambiguous" };
}
const messages = completedPaths[0];
return messages ? { kind: "found", messages } : { kind: "missing" };
}
async function applyWorkerTranscriptCommit(params: {
config: OpenClawConfig;
messages: readonly CommittedAgentMessage[];
recoverPersistedBatch: boolean;
requestedBaseLeafId: string | null;
sessionId: string;
target: ResolvedWorkerTranscriptTarget;
}): Promise<ApplyTranscriptCommitResult> {
const redactedMessages = params.messages.map(
(message) => redactTranscriptMessage(message, params.config) as CommittedAgentMessage,
);
const applied = await withTranscriptWriteTransaction(params.target, ({ sessionFile }) => {
const currentEntry = loadSessionEntry(params.target);
if (!currentEntry || currentEntry.sessionId !== params.sessionId) {
return { ok: false as const, reason: "session-not-attached" as const };
}
const manager = SessionManager.open(sessionFile);
if (params.recoverPersistedBatch) {
// Only a pending ledger row may prove an off-branch batch: the agent DB
// can commit before the shared replay ledger records its terminal result.
const recovered = resolvePersistedCommitAcrossDag({
baseLeafId: params.requestedBaseLeafId,
manager,
messages: redactedMessages,
});
if (recovered.kind === "found") {
return { ok: true as const, messages: recovered.messages };
}
if (recovered.kind === "ambiguous") {
return { ok: false as const, reason: "invalid-batch" as const };
}
}
const prefix = resolveActiveCommitPrefix({
baseLeafId: params.requestedBaseLeafId,
manager,
messages: redactedMessages,
});
if (!prefix.ok) {
return { ok: false as const, reason: "stale-base-leaf" as const };
}
const messages = [...prefix.recoveredMessages];
let nextMessageSeq = prefix.activeVisibleEntryCount;
for (const message of redactedMessages.slice(prefix.recoveredMessages.length)) {
const messageId = manager.appendMessage(message, {
config: params.config,
// Active-path recovery owns dedupe. A global key scan could reuse an
// id from an abandoned branch while SessionManager advances another id.
idempotencyLookup: "caller-checked",
});
nextMessageSeq += 1;
messages.push({
appended: true,
message,
messageId,
messageSeq: nextMessageSeq,
});
}
const freshEntry = loadSessionEntry(params.target);
if (!freshEntry || freshEntry.sessionId !== params.sessionId) {
return { ok: false as const, reason: "session-not-attached" as const };
}
const appendedCount = messages.filter((message) => message.appended).length;
const nextEntry = {
...freshEntry,
sessionFile,
...(appendedCount > 0 ? { updatedAt: Math.max(freshEntry.updatedAt ?? 0, Date.now()) } : {}),
};
replaceSessionEntrySync(params.target, nextEntry);
return { ok: true as const, messages };
});
if (!applied.ok) {
return applied;
}
for (const message of applied.messages) {
if (!message.appended) {
continue;
}
await publishTranscriptUpdate(params.target, {
message: message.message,
messageId: message.messageId,
messageSeq: message.messageSeq,
});
}
return applied;
}
/** Applies ordered, idempotent semantic worker turns to the canonical session transcript. */
export function createWorkerTranscriptCommitter(options: WorkerTranscriptCommitterOptions) {
const store = options.store ?? createWorkerTranscriptCommitStore();
const sessionOperations = new KeyedAsyncQueue();
const commit = async (params: {
identity: WorkerConnectionIdentity;
request: WorkerTranscriptCommitParams;
}): Promise<WorkerTranscriptCommitOutcome> => {
const sessionId = params.identity.sessionId;
if (!sessionId) {
return { ok: false, reason: "session-not-attached" };
}
if (params.request.runEpoch !== params.identity.ownerEpoch) {
return { ok: false, reason: "epoch-mismatch" };
}
return await sessionOperations.enqueue(sessionId, async () => {
const input: WorkerTranscriptCommitInput = {
environmentId: params.identity.environmentId,
sessionId,
runEpoch: params.request.runEpoch,
seq: params.request.seq,
requestHash: requestHash(params.request),
};
const started = store.begin(input);
if (started.kind === "replay") {
return started.outcome;
}
if (started.kind === "rejected") {
return { ok: false, reason: "invalid-batch" };
}
const config = options.getConfig();
const target = resolveWorkerTranscriptTarget(config, sessionId);
if (!target) {
return store.complete({
...input,
outcome: { ok: false, reason: "session-not-attached" },
});
}
const messages = params.request.messages.map((message, index) =>
buildCommittedMessage(
message,
messageIdempotencyKey({
sessionId,
runEpoch: params.request.runEpoch,
seq: params.request.seq,
index,
}),
),
);
const applied = await applyWorkerTranscriptCommit({
config,
messages,
recoverPersistedBatch: started.kind === "recover",
requestedBaseLeafId: params.request.baseLeafId,
sessionId,
target,
});
if (!applied.ok) {
return store.complete({ ...input, outcome: { ok: false, reason: applied.reason } });
}
const entryIds = applied.messages.map((message) => message.messageId);
const newLeafId = entryIds.at(-1);
if (entryIds.length !== params.request.messages.length || !newLeafId) {
return store.complete({
...input,
outcome: { ok: false, reason: "invalid-batch" },
});
}
return store.complete({
...input,
outcome: { ok: true, result: { entryIds, newLeafId } },
});
});
};
return { commit };
}
export type WorkerTranscriptCommitter = ReturnType<typeof createWorkerTranscriptCommitter>;
+21
View File
@@ -23,6 +23,7 @@ import {
OPENCLAW_AGENT_SCHEMA_VERSION,
openOpenClawAgentDatabase,
resolveOpenClawAgentSqlitePath,
runOpenClawAgentWriteTransaction,
} from "./openclaw-agent-db.js";
import {
closeOpenClawStateDatabaseForTest,
@@ -966,6 +967,26 @@ describe("openclaw agent database", () => {
expect(fs.statSync(parentDir).mode & 0o777).toBe(0o755);
});
it.runIf(process.platform !== "win32")(
"defers nested permission repair until the outer transaction commits",
() => {
const stateDir = createTempStateDir();
const options = {
agentId: "worker-1",
env: { OPENCLAW_STATE_DIR: stateDir },
};
const database = openOpenClawAgentDatabase(options);
fs.chmodSync(database.path, 0o644);
runOpenClawAgentWriteTransaction(() => {
runOpenClawAgentWriteTransaction(() => undefined, options);
expect(fs.statSync(database.path).mode & 0o777).toBe(0o644);
}, options);
expect(fs.statSync(database.path).mode & 0o777).toBe(0o600);
},
);
it("configures durable SQLite connection pragmas", () => {
const stateDir = createTempStateDir();
const database = openOpenClawAgentDatabase({
+6 -1
View File
@@ -831,13 +831,18 @@ export function runOpenClawAgentWriteTransaction<T>(
> = {},
): T {
const database = openOpenClawAgentDatabase(options);
const enteredNestedTransaction = database.db.isTransaction;
const result = runSqliteImmediateTransactionSync(database.db, () => operation(database), {
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: database.path,
...transactionOptions,
operationLabel: transactionOptions.operationLabel ?? "agent.write",
});
ensureOpenClawAgentDatabasePermissions(database.path, options);
// The outer owner repairs permissions after COMMIT; nested savepoint callers
// must not add filesystem work while that transaction is still open.
if (!enteredNestedTransaction) {
ensureOpenClawAgentDatabasePermissions(database.path, options);
}
return result;
}
+21
View File
@@ -1135,6 +1135,25 @@ export interface WorkerEnvironments {
updated_at_ms: number;
}
export interface WorkerTranscriptCommitHeads {
environment_id: string;
next_seq: number;
run_epoch: number;
session_id: string;
updated_at_ms: number;
}
export interface WorkerTranscriptCommits {
created_at_ms: number;
request_hash: string;
result_json: string | null;
run_epoch: number;
seq: number;
session_id: string;
state: string;
updated_at_ms: number;
}
export interface WorkspaceSetupState {
bootstrap_seeded_at: string | null;
setup_completed_at: string | null;
@@ -1238,6 +1257,8 @@ export interface DB {
web_push_vapid_keys: WebPushVapidKeys;
worker_environment_credentials: WorkerEnvironmentCredentials;
worker_environments: WorkerEnvironments;
worker_transcript_commit_heads: WorkerTranscriptCommitHeads;
worker_transcript_commits: WorkerTranscriptCommits;
workspace_setup_state: WorkspaceSetupState;
worktrees: Worktrees;
}
+35
View File
@@ -843,6 +843,41 @@ describe("openclaw state database", () => {
expect(credentialTable?.name).toBe("worker_environment_credentials");
});
it("adds worker transcript commit tables to existing state databases", () => {
const stateDir = createTempStateDir();
const database = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: stateDir },
});
const databasePath = database.path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
legacyDb.exec(`
DROP TABLE worker_transcript_commits;
DROP TABLE worker_transcript_commit_heads;
`);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
env: { OPENCLAW_STATE_DIR: stateDir },
});
const tables = reopened.db
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name IN (
'worker_transcript_commit_heads',
'worker_transcript_commits'
)
ORDER BY name`,
)
.all() as Array<{ name?: string }>;
expect(tables.map((table) => table.name)).toEqual([
"worker_transcript_commit_heads",
"worker_transcript_commits",
]);
});
it("migrates requester and executor attribution for existing cross-agent tasks", () => {
const stateDir = createTempStateDir();
const database = openOpenClawStateDatabase({
@@ -1594,6 +1594,38 @@ CREATE TABLE IF NOT EXISTS worker_environment_credentials (
FOREIGN KEY (environment_id) REFERENCES worker_environments(environment_id) ON DELETE CASCADE
);
-- One durable sequence cursor per attached session owner epoch. The environment
-- binding prevents independent workers with coincident epochs from sharing replay state.
CREATE TABLE IF NOT EXISTS worker_transcript_commit_heads (
session_id TEXT NOT NULL,
run_epoch INTEGER NOT NULL CHECK (run_epoch >= 0),
environment_id TEXT NOT NULL,
next_seq INTEGER NOT NULL CHECK (next_seq >= 1),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
PRIMARY KEY (session_id, run_epoch)
);
-- Pending rows preserve a claimed request across gateway restarts. Terminal rows
-- cache the exact result returned for deterministic at-least-once replay.
CREATE TABLE IF NOT EXISTS worker_transcript_commits (
session_id TEXT NOT NULL,
run_epoch INTEGER NOT NULL CHECK (run_epoch >= 0),
seq INTEGER NOT NULL CHECK (seq >= 1),
request_hash TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'terminal')),
result_json TEXT,
created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
PRIMARY KEY (session_id, run_epoch, seq),
FOREIGN KEY (session_id, run_epoch)
REFERENCES worker_transcript_commit_heads(session_id, run_epoch)
ON DELETE CASCADE,
CHECK (
(state = 'pending' AND result_json IS NULL) OR
(state = 'terminal' AND result_json IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS fleet_cells (
tenant_id TEXT NOT NULL PRIMARY KEY,
created_at_ms INTEGER NOT NULL,
+32
View File
@@ -1589,6 +1589,38 @@ CREATE TABLE IF NOT EXISTS worker_environment_credentials (
FOREIGN KEY (environment_id) REFERENCES worker_environments(environment_id) ON DELETE CASCADE
);
-- One durable sequence cursor per attached session owner epoch. The environment
-- binding prevents independent workers with coincident epochs from sharing replay state.
CREATE TABLE IF NOT EXISTS worker_transcript_commit_heads (
session_id TEXT NOT NULL,
run_epoch INTEGER NOT NULL CHECK (run_epoch >= 0),
environment_id TEXT NOT NULL,
next_seq INTEGER NOT NULL CHECK (next_seq >= 1),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
PRIMARY KEY (session_id, run_epoch)
);
-- Pending rows preserve a claimed request across gateway restarts. Terminal rows
-- cache the exact result returned for deterministic at-least-once replay.
CREATE TABLE IF NOT EXISTS worker_transcript_commits (
session_id TEXT NOT NULL,
run_epoch INTEGER NOT NULL CHECK (run_epoch >= 0),
seq INTEGER NOT NULL CHECK (seq >= 1),
request_hash TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'terminal')),
result_json TEXT,
created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
PRIMARY KEY (session_id, run_epoch, seq),
FOREIGN KEY (session_id, run_epoch)
REFERENCES worker_transcript_commit_heads(session_id, run_epoch)
ON DELETE CASCADE,
CHECK (
(state = 'pending' AND result_json IS NULL) OR
(state = 'terminal' AND result_json IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS fleet_cells (
tenant_id TEXT NOT NULL PRIMARY KEY,
created_at_ms INTEGER NOT NULL,