clawdbot-d02.1.9.1.31: add sessions.create lifecycle seam (#93691)

This commit is contained in:
Josh Lehman
2026-06-16 19:01:14 -07:00
committed by GitHub
parent 8b06d80655
commit cf64a9c517
6 changed files with 337 additions and 104 deletions
+54 -1
View File
@@ -34,6 +34,13 @@ const legacyTranscriptWriterNames = new Set([
"emitSessionTranscriptUpdate",
"rewriteTranscriptEntriesInSessionFile",
]);
const sessionCreateLifecycleWriterNames = new Set([
"applySessionStoreEntryPatch",
"saveSessionStore",
"updateSessionStore",
"updateSessionStoreEntry",
"ensureSessionTranscriptFile",
]);
export const migratedSessionAccessorFiles = new Set([
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
@@ -238,6 +245,39 @@ export function findTranscriptWriterBoundaryViolations(content, fileName = "sour
);
}
export function findGatewaySessionCreateLifecycleViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visitCreateHandler = (node) => {
if (ts.isCallExpression(node)) {
const calleeName = propertyAccessName(node.expression);
if (calleeName && sessionCreateLifecycleWriterNames.has(calleeName)) {
violations.push({
line: toLine(sourceFile, node.expression),
reason: `calls legacy sessions.create lifecycle writer "${calleeName}"`,
});
}
}
ts.forEachChild(node, visitCreateHandler);
};
const visit = (node) => {
if (
ts.isPropertyAssignment(node) &&
ts.isStringLiteralLike(node.name) &&
node.name.text === "sessions.create"
) {
visitCreateHandler(node.initializer);
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export async function main() {
const repoRoot = resolveRepoRoot(import.meta.url);
const readSourceRoots = resolveSourceRoots(repoRoot, [
@@ -287,7 +327,20 @@ export async function main() {
!migratedTranscriptWriterFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
findViolations: findTranscriptWriterBoundaryViolations,
});
const violations = [...readViolations, ...writeViolations, ...transcriptWriterViolations];
const sessionCreateLifecycleViolations = await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
skipFile: (filePath) =>
normalizeRelativePath(path.relative(repoRoot, filePath)) !==
"src/gateway/server-methods/sessions.ts",
findViolations: findGatewaySessionCreateLifecycleViolations,
});
const violations = [
...readViolations,
...writeViolations,
...transcriptWriterViolations,
...sessionCreateLifecycleViolations,
];
if (violations.length === 0) {
console.log("session accessor boundary guard passed.");
@@ -7,6 +7,7 @@ import {
appendTranscriptMessage,
appendTranscriptEvent,
cleanupSessionLifecycleArtifacts,
createSessionEntryWithTranscript,
listSessionEntries,
loadExactSessionEntry,
loadSessionEntry,
@@ -93,6 +94,57 @@ describe("session accessor file-backed seam", () => {
expect(loadSessionEntry(scope)?.sessionId).toBe(inserted?.sessionId);
});
it("creates entries with initialized transcripts and normalized sessionFile metadata", async () => {
const scope = {
agentId: "main",
sessionKey: "agent:main:main",
storePath,
};
const created = await createSessionEntryWithTranscript(scope, ({ sessionEntries }) => {
expect(sessionEntries).toEqual({});
return {
ok: true,
entry: {
sessionId: "session-1",
updatedAt: 10,
},
};
});
expect(created.ok).toBe(true);
if (!created.ok) {
throw new Error("expected session creation to succeed");
}
expect(path.basename(created.sessionFile)).toBe("session-1.jsonl");
expect(created.entry.sessionFile).toBe(created.sessionFile);
});
it("rolls back the entry when transcript initialization fails", async () => {
const scope = {
agentId: "main",
sessionKey: "agent:main:main",
storePath,
};
fs.writeFileSync(path.join(tempDir, "blocked"), "not a directory", "utf8");
const created = await createSessionEntryWithTranscript(scope, () => ({
ok: true,
entry: {
sessionFile: "blocked/session-1.jsonl",
sessionId: "session-1",
updatedAt: 10,
},
}));
expect(created).toMatchObject({
ok: false,
phase: "transcript",
});
expect(loadSessionEntry(scope)).toBeUndefined();
expect(loadSessionStore(storePath, { skipCache: true })[scope.sessionKey]).toBeUndefined();
});
it("can borrow cached entry objects for read-only hot paths", async () => {
const scope = {
clone: false,
+118
View File
@@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
acquireSessionWriteLock,
resolveSessionWriteLockOptions,
} from "../../agents/session-write-lock.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import type { SessionTranscriptUpdate } from "../../sessions/transcript-events.js";
@@ -26,6 +28,7 @@ import {
patchSessionEntry as patchFileSessionEntry,
readSessionUpdatedAt as readFileSessionUpdatedAt,
resolveSessionStoreEntry,
updateSessionStore,
updateSessionStoreEntry as updateFileSessionStoreEntry,
type SessionLifecycleArtifactCleanupParams,
type SessionLifecycleArtifactCleanupResult,
@@ -40,6 +43,7 @@ import {
withSessionTranscriptAppendQueue,
} from "./transcript-append.js";
import { resolveSessionTranscriptFile } from "./transcript-file-resolve.js";
import { createSessionTranscriptHeader } from "./transcript-header.js";
import { streamSessionTranscriptLines } from "./transcript-stream.js";
import {
type OwnedSessionTranscriptPublishedEntry,
@@ -243,6 +247,26 @@ export type SessionEntryPatchContext = {
existingEntry?: SessionEntry;
};
export type SessionEntryCreateWithTranscriptContext = {
/** Current entry under the requested key before creation, if any. */
existingEntry?: SessionEntry;
/** Current entries snapshot for validation rules such as label uniqueness. */
sessionEntries: Record<string, SessionEntry>;
};
export type SessionEntryCreateWithTranscriptResult<TError = string> =
| { ok: true; entry: SessionEntry; sessionFile: string }
| { ok: false; error: TError; phase: "entry" }
| { ok: false; error: string; phase: "transcript" };
export type SessionEntryCreateWithTranscriptPrepareResult<TError = string> =
| { ok: true; entry: SessionEntry }
| { ok: false; error: TError };
type CreatedSessionTranscriptResult =
| { ok: true; sessionFile: string }
| { ok: false; error: string; phase: "transcript" };
export type { SessionLifecycleArtifactCleanupParams, SessionLifecycleArtifactCleanupResult };
/** Returns the entry for a canonical or alias session key, if one exists. */
@@ -349,6 +373,100 @@ export async function patchSessionEntry(
});
}
/**
* Creates or updates one session entry and initializes its transcript header as
* one storage-sized lifecycle operation. File-backed storage still writes JSON
* plus JSONL, but callers no longer compose entry write, header creation,
* rollback, and normalized sessionFile persistence themselves.
*/
export async function createSessionEntryWithTranscript<TError = string>(
scope: SessionAccessScope,
createEntry: (
context: SessionEntryCreateWithTranscriptContext,
) =>
| Promise<SessionEntryCreateWithTranscriptPrepareResult<TError>>
| SessionEntryCreateWithTranscriptPrepareResult<TError>,
): Promise<SessionEntryCreateWithTranscriptResult<TError>> {
const storePath = resolveAccessStorePath(scope);
return await updateSessionStore(storePath, async (store) => {
const resolved = resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
const created = await createEntry({
existingEntry: resolved.existing ? { ...resolved.existing } : undefined,
sessionEntries: cloneSessionEntries(store),
});
if (!created.ok) {
return { ok: false, error: created.error, phase: "entry" };
}
const ensured = ensureCreatedSessionTranscript({
agentId: scope.agentId,
entry: created.entry,
storePath,
});
if (!ensured.ok) {
delete store[resolved.normalizedKey];
return ensured;
}
const entry =
created.entry.sessionFile === ensured.sessionFile
? created.entry
: {
...created.entry,
sessionFile: ensured.sessionFile,
};
store[resolved.normalizedKey] = entry;
for (const legacyKey of resolved.legacyKeys) {
delete store[legacyKey];
}
return { ok: true, entry, sessionFile: ensured.sessionFile };
});
}
function cloneSessionEntries(store: Record<string, SessionEntry>): Record<string, SessionEntry> {
return Object.fromEntries(
Object.entries(store).map(([sessionKey, entry]) => [sessionKey, { ...entry }]),
);
}
// File-backed creation resolves the concrete transcript artifact and writes the
// header before the store mutation is saved; SQLite adapters implement this as
// the same lifecycle operation without exposing rollback details to callers.
function ensureCreatedSessionTranscript(params: {
agentId?: string;
entry: SessionEntry;
storePath: string;
}): CreatedSessionTranscriptResult {
try {
const sessionFile = resolveSessionFilePath(
params.entry.sessionId,
params.entry.sessionFile ? { sessionFile: params.entry.sessionFile } : undefined,
{
agentId: params.agentId,
sessionsDir: path.dirname(path.resolve(params.storePath)),
},
);
if (!fs.existsSync(sessionFile)) {
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(
sessionFile,
`${JSON.stringify(createSessionTranscriptHeader({ sessionId: params.entry.sessionId }))}\n`,
{
encoding: "utf-8",
mode: 0o600,
},
);
}
return { ok: true, sessionFile };
} catch (err) {
return {
ok: false,
error: formatErrorMessage(err),
phase: "transcript",
};
}
}
/** Updates an existing entry only; returns null when the session is absent. */
export async function updateSessionEntry(
scope: SessionAccessScope,
+43 -103
View File
@@ -52,14 +52,12 @@ import {
runSessionsCleanup,
serializeSessionCleanupResult,
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
listConfiguredSessionStoreAgentIds,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
import { createSessionEntryWithTranscript } from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
createInternalHookEvent,
@@ -497,44 +495,6 @@ async function createAgentMainSessionForSend(params: {
};
}
function ensureSessionTranscriptFile(params: {
sessionId: string;
storePath: string;
sessionFile?: string;
agentId: string;
}): { ok: true; transcriptPath: string } | { ok: false; error: string } {
try {
const transcriptPath = resolveSessionFilePath(
params.sessionId,
params.sessionFile ? { sessionFile: params.sessionFile } : undefined,
resolveSessionFilePathOptions({
storePath: params.storePath,
agentId: params.agentId,
}),
);
if (!fs.existsSync(transcriptPath)) {
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
fs.writeFileSync(transcriptPath, `${JSON.stringify(header)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
}
return { ok: true, transcriptPath };
} catch (err) {
return {
ok: false,
error: formatErrorMessage(err),
};
}
}
function resolveAbortSessionKey(params: {
context: Pick<GatewayRequestContext, "chatAbortControllers">;
requestedKey: string;
@@ -1511,76 +1471,56 @@ export const sessionsHandlers: GatewayRequestHandlers = {
: buildDashboardSessionKey(agentId);
const target = resolveGatewaySessionStoreTarget({ cfg, key, agentId });
const targetAgentId = target.agentId;
const created = await updateSessionStore(target.storePath, async (store) => {
const patched = await applySessionsPatchToStore({
cfg,
store,
storeKey: target.canonicalKey,
const created = await createSessionEntryWithTranscript(
{
agentId: targetAgentId,
patch: {
key: target.canonicalKey,
label: normalizeOptionalString(p.label),
model: normalizeOptionalString(p.model),
},
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
});
if (!patched.ok || !canonicalParentSessionKey) {
return patched;
}
const inheritedSelection = normalizeOptionalString(p.model)
? {}
: inheritSessionRuntimeSelection(parentSessionEntry);
const nextEntry: SessionEntry = {
...patched.entry,
...inheritedSelection,
parentSessionKey: canonicalParentSessionKey,
};
store[target.canonicalKey] = nextEntry;
return {
...patched,
entry: nextEntry,
};
});
sessionKey: target.canonicalKey,
storePath: target.storePath,
},
async ({ sessionEntries }) => {
const patched = await applySessionsPatchToStore({
cfg,
store: sessionEntries,
storeKey: target.canonicalKey,
agentId: targetAgentId,
patch: {
key: target.canonicalKey,
label: normalizeOptionalString(p.label),
model: normalizeOptionalString(p.model),
},
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
});
if (!patched.ok || !canonicalParentSessionKey) {
return patched;
}
const inheritedSelection = normalizeOptionalString(p.model)
? {}
: inheritSessionRuntimeSelection(parentSessionEntry);
const nextEntry: SessionEntry = {
...patched.entry,
...inheritedSelection,
parentSessionKey: canonicalParentSessionKey,
};
return {
...patched,
entry: nextEntry,
};
},
);
if (!created.ok) {
respond(false, undefined, created.error);
return;
}
const ensured = ensureSessionTranscriptFile({
sessionId: created.entry.sessionId,
storePath: target.storePath,
sessionFile: created.entry.sessionFile,
agentId: targetAgentId,
});
if (!ensured.ok) {
await updateSessionStore(target.storePath, (store) => {
delete store[target.canonicalKey];
});
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `failed to create session transcript: ${ensured.error}`),
created.phase === "transcript"
? errorShape(
ErrorCodes.UNAVAILABLE,
`failed to create session transcript: ${created.error}`,
)
: created.error,
);
return;
}
const createdEntry =
created.entry.sessionFile === ensured.transcriptPath
? created.entry
: {
...created.entry,
sessionFile: ensured.transcriptPath,
};
if (createdEntry !== created.entry) {
await updateSessionStore(target.storePath, (store) => {
const existing = store[target.canonicalKey];
if (existing) {
store[target.canonicalKey] = {
...existing,
sessionFile: ensured.transcriptPath,
};
}
});
}
const createdEntry = created.entry;
const initialMessage = resolveOptionalInitialSessionMessage(p);
let runPayload: Record<string, unknown> | undefined;
@@ -82,6 +82,7 @@ test("sessions.create stores dashboard session model and parent linkage, and cre
expect(sessionFile).toBe(rawStore[key]?.sessionFile);
const transcriptPath = path.join(dir, `${created.payload?.sessionId}.jsonl`);
await expect(fs.realpath(sessionFile)).resolves.toBe(await fs.realpath(transcriptPath));
const transcript = await fs.readFile(transcriptPath, "utf-8");
const [headerLine] = transcript.trim().split(/\r?\n/, 1);
const header = JSON.parse(headerLine) as { type?: string; id?: string };
@@ -259,6 +260,40 @@ test("sessions.create replaces a dead main entry with a fresh session id", async
}
});
test("sessions.create rolls back the entry when transcript initialization fails", async () => {
const { dir, storePath } = await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
const blockerPath = path.join(dir, "blocked");
await fs.writeFile(blockerPath, "not a directory", "utf-8");
try {
await writeSessionStore({
agentId: "ops",
entries: {
main: {
sessionFile: "blocked/session-1.jsonl",
sessionId: "session-1",
updatedAt: 1,
},
},
});
const created = await directSessionReq("sessions.create", {
key: "main",
agentId: "ops",
});
expect(created.ok).toBe(false);
expect((created.error as { code?: string } | undefined)?.code).toBe("UNAVAILABLE");
expect((created.error as { message?: string } | undefined)?.message ?? "").toContain(
"failed to create session transcript:",
);
const rawStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<string, unknown>;
expect(rawStore["agent:ops:main"]).toBeUndefined();
} finally {
testState.agentsConfig = undefined;
}
});
test("sessions.create preserves global and unknown sentinel keys", async () => {
const { storePath } = await createSessionStoreDir();
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
findGatewaySessionCreateLifecycleViolations,
findSessionAccessorBoundaryViolations,
findSessionAccessorWriteBoundaryViolations,
findTranscriptWriterBoundaryViolations,
@@ -239,6 +240,40 @@ describe("session accessor boundary guard", () => {
).toEqual([]);
});
it("flags legacy writers inside the gateway sessions.create lifecycle", () => {
expect(
findGatewaySessionCreateLifecycleViolations(`
const handlers = {
"sessions.create": async () => {
await updateSessionStore(storePath, () => undefined);
ensureSessionTranscriptFile(params);
},
"sessions.patch": async () => {
await updateSessionStore(storePath, () => undefined);
},
};
`),
).toEqual([
{ line: 4, reason: 'calls legacy sessions.create lifecycle writer "updateSessionStore"' },
{
line: 5,
reason: 'calls legacy sessions.create lifecycle writer "ensureSessionTranscriptFile"',
},
]);
});
it("allows the gateway sessions.create lifecycle accessor seam", () => {
expect(
findGatewaySessionCreateLifecycleViolations(`
const handlers = {
"sessions.create": async () => {
await createSessionEntryWithTranscript(scope, createEntry);
},
};
`),
).toEqual([]);
});
it("ignores comments and strings that describe legacy readers", () => {
expect(
findSessionAccessorBoundaryViolations(`