refactor: fork sessions with sqlite transcript locators

This commit is contained in:
Peter Steinberger
2026-05-08 13:36:41 +01:00
parent 4bb747fa20
commit ee121ff010
2 changed files with 31 additions and 27 deletions

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createSqliteSessionTranscriptLocator } from "../../config/sessions/paths.js";
import {
loadSqliteSessionTranscriptEvents,
replaceSqliteSessionTranscriptEvents,
@@ -61,10 +62,9 @@ describe("resolveParentForkTokenCountRuntime", () => {
it("falls back to recent transcript usage when cached totals are stale", async () => {
const root = await makeRoot("openclaw-parent-fork-token-estimate-");
useStateRoot(root);
const transcriptDir = path.join(root, "transcript-fixtures");
const sessionId = "parent-overflow-transcript";
const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`);
const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId });
const events: unknown[] = [
{
type: "session",
@@ -118,10 +118,9 @@ describe("resolveParentForkTokenCountRuntime", () => {
it("falls back to a conservative byte estimate when stale parent transcript has no usage", async () => {
const root = await makeRoot("openclaw-parent-fork-byte-estimate-");
useStateRoot(root);
const transcriptDir = path.join(root, "transcript-fixtures");
const sessionId = "parent-no-usage-transcript";
const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`);
const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId });
const events: unknown[] = [
{
type: "session",
@@ -160,10 +159,9 @@ describe("resolveParentForkTokenCountRuntime", () => {
it("uses the latest usage snapshot instead of tail aggregates for parent fork checks", async () => {
const root = await makeRoot("openclaw-parent-fork-latest-usage-");
useStateRoot(root);
const transcriptDir = path.join(root, "transcript-fixtures");
const sessionId = "parent-multiple-usage-transcript";
const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`);
const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId });
seedTranscript({
sessionId,
transcriptPath: sessionFile,
@@ -210,10 +208,9 @@ describe("resolveParentForkTokenCountRuntime", () => {
it("keeps parent fork checks conservative for content appended after latest usage", async () => {
const root = await makeRoot("openclaw-parent-fork-post-usage-tail-");
useStateRoot(root);
const transcriptDir = path.join(root, "transcript-fixtures");
const sessionId = "parent-post-usage-tail";
const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`);
const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId });
seedTranscript({
sessionId,
transcriptPath: sessionFile,
@@ -261,11 +258,13 @@ describe("forkSessionFromParentRuntime", () => {
it("forks the active branch without synchronously opening the session manager", async () => {
const root = await makeRoot("openclaw-parent-fork-");
useStateRoot(root);
const transcriptDir = path.join(root, "transcript-fixtures");
const cwd = path.join(root, "workspace");
await fs.mkdir(cwd);
const parentSessionId = "parent-session";
const parentSessionFile = path.join(transcriptDir, `${parentSessionId}.jsonl`);
const parentSessionFile = createSqliteSessionTranscriptLocator({
agentId: "main",
sessionId: parentSessionId,
});
const events = [
{
type: "session",
@@ -319,14 +318,16 @@ describe("forkSessionFromParentRuntime", () => {
if (fork === null) {
throw new Error("Expected forked session");
}
expect(fork.sessionFile).toBe(fork.sessionId);
expect(fork.sessionFile).toBe(
createSqliteSessionTranscriptLocator({ agentId: "main", sessionId: fork.sessionId }),
);
expect(fork.sessionId).not.toBe(parentSessionId);
const forkedEntries = readTranscript("main", fork.sessionId) as Array<Record<string, unknown>>;
expect(forkedEntries[0]).toMatchObject({
type: "session",
id: fork.sessionId,
cwd,
parentSession: path.resolve(parentSessionFile),
parentSession: parentSessionFile,
});
expect(forkedEntries.map((entry) => entry.type)).toEqual([
"session",
@@ -344,9 +345,11 @@ describe("forkSessionFromParentRuntime", () => {
it("creates a header-only child when the parent has no entries", async () => {
const root = await makeRoot("openclaw-parent-fork-empty-");
useStateRoot(root);
const transcriptDir = path.join(root, "transcript-fixtures");
const parentSessionId = "parent-empty";
const parentSessionFile = path.join(transcriptDir, `${parentSessionId}.jsonl`);
const parentSessionFile = createSqliteSessionTranscriptLocator({
agentId: "main",
sessionId: parentSessionId,
});
seedTranscript({
sessionId: parentSessionId,
transcriptPath: parentSessionFile,
@@ -378,7 +381,7 @@ describe("forkSessionFromParentRuntime", () => {
expect(entries[0]).toMatchObject({
type: "session",
id: fork.sessionId,
parentSession: path.resolve(parentSessionFile),
parentSession: parentSessionFile,
});
});
});

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import path from "node:path";
import {
CURRENT_SESSION_VERSION,
migrateSessionEntries,
@@ -9,8 +8,8 @@ import {
} from "../../agents/transcript/session-transcript-contract.js";
import { derivePromptTokens } from "../../agents/usage.js";
import {
resolveSessionFilePath,
resolveSessionFilePathOptions,
createSqliteSessionTranscriptLocator,
isSqliteSessionTranscriptLocator,
} from "../../config/sessions/paths.js";
import {
loadSqliteSessionTranscriptEvents,
@@ -76,14 +75,10 @@ async function estimateParentTranscriptTokensFromSqlite(params: {
function resolveForkParentSessionFile(parentEntry: StoreSessionEntry, agentId: string): string {
const sessionFile = parentEntry.sessionFile?.trim();
if (sessionFile && path.isAbsolute(sessionFile)) {
return path.resolve(sessionFile);
if (sessionFile && isSqliteSessionTranscriptLocator(sessionFile)) {
return sessionFile;
}
return resolveSessionFilePath(
parentEntry.sessionId,
parentEntry,
resolveSessionFilePathOptions({ agentId }),
);
return createSqliteSessionTranscriptLocator({ agentId, sessionId: parentEntry.sessionId });
}
export async function resolveParentForkTokenCountRuntime(params: {
@@ -248,7 +243,10 @@ async function writeForkHeaderOnly(params: {
}): Promise<{ sessionId: string; sessionFile: string }> {
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const sessionFile = sessionId;
const sessionFile = createSqliteSessionTranscriptLocator({
agentId: params.agentId,
sessionId,
});
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
@@ -272,7 +270,10 @@ async function writeBranchedSession(params: {
}): Promise<{ sessionId: string; sessionFile: string }> {
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const sessionFile = sessionId;
const sessionFile = createSqliteSessionTranscriptLocator({
agentId: params.source.agentId,
sessionId,
});
const pathWithoutLabels = params.source.branchEntries.filter((entry) => entry.type !== "label");
const pathEntryIds = new Set(pathWithoutLabels.map((entry) => entry.id));
const labelEntries = buildBranchLabelEntries({