refactor(agent): tighten harness session storage

This commit is contained in:
Mario Zechner
2026-05-03 13:21:14 +02:00
parent 83599e789d
commit e612149369
6 changed files with 411 additions and 186 deletions

View File

@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import type { CodingAgentSessionInfo, SessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
import { createInterface } from "node:readline";
import type { JsonlSessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
interface SessionHeader {
type: "session";
@@ -12,116 +13,140 @@ interface SessionHeader {
parentSession?: string;
}
function headerToSessionInfo(header: SessionHeader, filePath?: string): CodingAgentSessionInfo {
function headerToSessionInfo(header: SessionHeader, path: string): JsonlSessionInfo {
return {
id: header.id,
createdAt: header.timestamp,
parentSession: header.parentSession,
projectCwd: header.cwd,
filePath,
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
};
}
async function loadJsonlStorage(
filePath: string,
): Promise<{ header?: SessionHeader; entries: SessionTreeEntry[]; leafId: string | null }> {
export async function loadJsonlSessionInfo(filePath: string): Promise<JsonlSessionInfo> {
const stream = createReadStream(filePath, { encoding: "utf8" });
const lines = createInterface({ input: stream, crlfDelay: Infinity });
try {
const content = await readFile(filePath, "utf8");
const entries: SessionTreeEntry[] = [];
let header: SessionHeader | undefined;
let leafId: string | null = null;
for (const line of content.split("\n")) {
if (!line.trim()) continue;
for await (const line of lines) {
if (!line.trim()) break;
try {
const record = JSON.parse(line) as SessionHeader | SessionTreeEntry;
if (record.type === "session") {
header = record as SessionHeader;
continue;
}
entries.push(record as SessionTreeEntry);
leafId = (record as SessionTreeEntry).id;
const header = JSON.parse(line) as SessionHeader;
return headerToSessionInfo(header, resolve(filePath));
} catch {
// ignore malformed lines
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
}
return { header, entries, leafId };
} catch {
return { entries: [], leafId: null };
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
} finally {
lines.close();
stream.destroy();
}
}
export class JsonlSessionTreeStorage implements SessionTreeStorage {
private filePath: string;
private cwd: string;
private headerInitialized = false;
private cacheLoaded = false;
private sessionInfo?: CodingAgentSessionInfo;
private entries: SessionTreeEntry[] = [];
private byId = new Map<string, SessionTreeEntry>();
private currentLeafId: string | null = null;
private requestedSessionId?: string;
private parentSession?: string;
async function loadJsonlStorage(filePath: string): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = await readFile(filePath, "utf8");
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
}
constructor(filePath: string, options: { cwd: string; sessionId?: string; parentSession?: string }) {
let header: SessionHeader;
try {
header = JSON.parse(lines[0]!) as SessionHeader;
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line) as SessionTreeEntry;
entries.push(entry);
leafId = entry.id;
} catch {
// ignore malformed entry lines
}
}
return { header, entries, leafId };
}
export class JsonlSessionTreeStorage implements SessionTreeStorage<JsonlSessionInfo> {
private readonly filePath: string;
private readonly header: SessionHeader;
private readonly sessionInfo: JsonlSessionInfo;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private currentLeafId: string | null;
private headerWritten: boolean;
private constructor(
filePath: string,
header: SessionHeader,
entries: SessionTreeEntry[],
leafId: string | null,
headerWritten: boolean,
) {
this.filePath = resolve(filePath);
this.cwd = options.cwd;
this.requestedSessionId = options.sessionId;
this.parentSession = options.parentSession;
this.header = header;
this.sessionInfo = headerToSessionInfo(header, this.filePath);
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.currentLeafId = leafId;
this.headerWritten = headerWritten;
}
private async ensureParentDir(): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
static async open(filePath: string): Promise<JsonlSessionTreeStorage> {
const resolvedPath = resolve(filePath);
const loaded = await loadJsonlStorage(resolvedPath);
return new JsonlSessionTreeStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId, true);
}
private async ensureLoaded(): Promise<void> {
if (this.cacheLoaded) {
return;
}
const loaded = await loadJsonlStorage(this.filePath);
this.entries = loaded.entries;
this.byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
this.currentLeafId = loaded.leafId;
this.headerInitialized = loaded.header !== undefined;
if (loaded.header) {
this.sessionInfo = headerToSessionInfo(loaded.header, this.filePath);
}
this.cacheLoaded = true;
}
private async ensureHeader(): Promise<void> {
await this.ensureLoaded();
if (this.headerInitialized) return;
await this.ensureParentDir();
static async create(
filePath: string,
options: {
cwd: string;
sessionId: string;
parentSessionPath?: string;
},
): Promise<JsonlSessionTreeStorage> {
const resolvedPath = resolve(filePath);
const header: SessionHeader = {
type: "session",
version: 3,
id: this.requestedSessionId ?? randomUUID(),
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: this.cwd,
parentSession: this.parentSession,
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
await writeFile(this.filePath, `${JSON.stringify(header)}\n`);
this.sessionInfo = headerToSessionInfo(header, this.filePath);
this.headerInitialized = true;
return new JsonlSessionTreeStorage(resolvedPath, header, [], null, false);
}
async getSessionInfo(): Promise<SessionInfo> {
await this.ensureHeader();
return this.sessionInfo!;
async getSessionInfo(): Promise<JsonlSessionInfo> {
return this.sessionInfo;
}
async getLeafId(): Promise<string | null> {
await this.ensureLoaded();
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
await this.ensureLoaded();
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
}
this.currentLeafId = leafId;
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
await this.ensureHeader();
if (!this.headerWritten) {
await mkdir(dirname(this.filePath), { recursive: true });
await writeFile(this.filePath, `${JSON.stringify(this.header)}\n`);
this.headerWritten = true;
}
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
this.entries.push(entry);
this.byId.set(entry.id, entry);
@@ -129,12 +154,10 @@ export class JsonlSessionTreeStorage implements SessionTreeStorage {
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
await this.ensureLoaded();
return this.byId.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
await this.ensureLoaded();
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
@@ -146,7 +169,6 @@ export class JsonlSessionTreeStorage implements SessionTreeStorage {
}
async getEntries(): Promise<SessionTreeEntry[]> {
await this.ensureLoaded();
return [...this.entries];
}
}

View File

@@ -1,15 +1,20 @@
import { randomUUID } from "crypto";
import { v7 as uuidv7 } from "uuid";
import type { SessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
export class InMemorySessionTreeStorage implements SessionTreeStorage {
private readonly sessionInfo: SessionInfo;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private leafId: string | null;
private sessionInfo: SessionInfo;
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; sessionInfo?: SessionInfo }) {
this.entries = options?.entries ? [...options.entries] : [];
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
this.sessionInfo = options?.sessionInfo ?? { id: randomUUID(), createdAt: new Date().toISOString() };
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new Error(`Entry ${this.leafId} not found`);
}
this.sessionInfo = options?.sessionInfo ?? { id: uuidv7(), createdAt: new Date().toISOString() };
}
async getSessionInfo(): Promise<SessionInfo> {
@@ -21,26 +26,29 @@ export class InMemorySessionTreeStorage implements SessionTreeStorage {
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
}
this.leafId = leafId;
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = entry.id;
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.entries.find((entry) => entry.id === id);
return this.byId.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const byId = new Map<string, SessionTreeEntry>(this.entries.map((entry) => [entry.id, entry]));
const path: SessionTreeEntry[] = [];
let current = byId.get(leafId);
let current = this.byId.get(leafId);
while (current) {
path.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
return path;
}

View File

@@ -1,13 +1,14 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { v7 as uuidv7 } from "uuid";
import type {
CodingAgentSessionInfo,
CodingAgentSessionRepo,
JsonlSessionInfo,
JsonlSessionRepo,
Session,
SessionInfo,
SessionRepo,
SessionTreeEntry,
SessionTreeStorage,
} from "../types.js";
import { JsonlSessionTreeStorage } from "./jsonl-session-storage.js";
import { InMemorySessionTreeStorage } from "./memory-session-storage.js";
@@ -21,8 +22,11 @@ function createTimestamp(): string {
return new Date().toISOString();
}
function toSession<TInfo extends SessionInfo>(info: TInfo, tree: DefaultSessionTree): Session<TInfo> {
return { info, tree };
function toSession<TInfo extends SessionInfo>(
storage: SessionTreeStorage<TInfo>,
tree: DefaultSessionTree<TInfo>,
): Session<TInfo> {
return { storage, tree };
}
function getPathEntriesToFork(
@@ -59,14 +63,13 @@ function getPathEntriesToFork(
export class InMemorySessionRepo implements SessionRepo<string> {
private sessions = new Map<string, Session<SessionInfo>>();
async create(options?: { id?: string; parentSession?: string }): Promise<Session<SessionInfo>> {
async create(options?: { id?: string }): Promise<Session<SessionInfo>> {
const info: SessionInfo = {
id: options?.id ?? createSessionId(),
createdAt: createTimestamp(),
parentSession: options?.parentSession,
};
const storage = new InMemorySessionTreeStorage({ sessionInfo: info });
const session = toSession(info, new DefaultSessionTree(storage));
const session = toSession(storage, new DefaultSessionTree(storage));
this.sessions.set(info.id, session);
return session;
}
@@ -97,42 +100,16 @@ export class InMemorySessionRepo implements SessionRepo<string> {
const info: SessionInfo = {
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
parentSession: source.info.id,
};
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
const storage = new InMemorySessionTreeStorage({ sessionInfo: info, entries: forkedEntries, leafId });
const session = toSession(info, new DefaultSessionTree(storage));
const session = toSession(storage, new DefaultSessionTree(storage));
this.sessions.set(info.id, session);
return session;
}
}
function readJsonlHeader(filePath: string): CodingAgentSessionInfo | undefined {
try {
const content = readFileSync(filePath, "utf8");
const firstLine = content.split("\n")[0];
if (!firstLine) return undefined;
const header = JSON.parse(firstLine) as {
type: string;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
};
if (header.type !== "session") return undefined;
return {
id: header.id,
createdAt: header.timestamp,
parentSession: header.parentSession,
projectCwd: header.cwd,
filePath,
};
} catch {
return undefined;
}
}
export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<string> {
export class JsonlSessionFileRepo implements JsonlSessionRepo<string> {
private sessionDir: string;
private cwd: string;
@@ -146,55 +123,66 @@ export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<strin
return join(this.sessionDir, `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
}
async create(options?: { id?: string; parentSession?: string }): Promise<Session<CodingAgentSessionInfo>> {
async create(options?: { id?: string; parentSessionPath?: string }): Promise<Session<JsonlSessionInfo>> {
const id = options?.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(id, createdAt);
const storage = new JsonlSessionTreeStorage(filePath, {
const storage = await JsonlSessionTreeStorage.create(filePath, {
cwd: this.cwd,
sessionId: id,
parentSession: options?.parentSession,
parentSessionPath: options?.parentSessionPath,
});
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
return toSession(info, new DefaultSessionTree(storage));
return toSession(storage, new DefaultSessionTree(storage));
}
async open(ref: string): Promise<Session<CodingAgentSessionInfo>> {
async open(ref: string): Promise<Session<JsonlSessionInfo>> {
const filePath = ref.includes("/") || ref.endsWith(".jsonl") ? resolve(ref) : join(this.sessionDir, ref);
if (!existsSync(filePath)) {
throw new Error(`Session not found: ${ref}`);
}
const storage = new JsonlSessionTreeStorage(filePath, { cwd: this.cwd });
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
return toSession(info, new DefaultSessionTree(storage));
const storage = await JsonlSessionTreeStorage.open(filePath);
return toSession(storage, new DefaultSessionTree(storage));
}
async list(): Promise<Array<Session<CodingAgentSessionInfo>>> {
async list(): Promise<Array<Session<JsonlSessionInfo>>> {
if (!existsSync(this.sessionDir)) {
return [];
}
const files = readdirSync(this.sessionDir)
.filter((file) => file.endsWith(".jsonl"))
.map((file) => join(this.sessionDir, file));
const sessions: Array<Session<CodingAgentSessionInfo>> = [];
const sessions: Array<Session<JsonlSessionInfo>> = [];
for (const filePath of files) {
const info = readJsonlHeader(filePath);
if (!info) continue;
sessions.push(
toSession(info, new DefaultSessionTree(new JsonlSessionTreeStorage(filePath, { cwd: info.projectCwd }))),
);
try {
const storage = await JsonlSessionTreeStorage.open(filePath);
sessions.push(toSession(storage, new DefaultSessionTree(storage)));
} catch {
// Ignore invalid session files when listing a directory.
}
}
return sessions;
}
async listByCwd(cwd: string): Promise<Array<Session<CodingAgentSessionInfo>>> {
return (await this.list()).filter((session) => session.info.projectCwd === cwd);
async listByCwd(cwd: string): Promise<Array<Session<JsonlSessionInfo>>> {
const sessions = await this.list();
const result: Array<Session<JsonlSessionInfo>> = [];
for (const session of sessions) {
if ((await session.storage.getSessionInfo()).cwd === cwd) {
result.push(session);
}
}
return result;
}
async getMostRecentByCwd(cwd: string): Promise<Session<CodingAgentSessionInfo> | undefined> {
const sessions = await this.listByCwd(cwd);
sessions.sort((a, b) => new Date(b.info.createdAt).getTime() - new Date(a.info.createdAt).getTime());
return sessions[0];
async getMostRecentByCwd(cwd: string): Promise<Session<JsonlSessionInfo> | undefined> {
const sessionsWithInfo = await Promise.all(
(await this.listByCwd(cwd)).map(async (session) => ({
session,
info: await session.storage.getSessionInfo(),
})),
);
sessionsWithInfo.sort((a, b) => new Date(b.info.createdAt).getTime() - new Date(a.info.createdAt).getTime());
return sessionsWithInfo[0]?.session;
}
async delete(ref: string): Promise<void> {
@@ -207,17 +195,18 @@ export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<strin
async fork(
ref: string,
options: { entryId: string; position?: "before" | "at"; id?: string },
): Promise<Session<CodingAgentSessionInfo>> {
): Promise<Session<JsonlSessionInfo>> {
const source = await this.open(ref);
const entries = await source.tree.getEntries();
const forkedEntries = getPathEntriesToFork(entries, options.entryId, options.position ?? "before");
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(id, createdAt);
const storage = new JsonlSessionTreeStorage(filePath, {
cwd: source.info.projectCwd,
const sourceInfo = await source.storage.getSessionInfo();
const storage = await JsonlSessionTreeStorage.create(filePath, {
cwd: sourceInfo.cwd,
sessionId: id,
parentSession: source.info.filePath ?? source.info.id,
parentSessionPath: sourceInfo.path,
});
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
@@ -225,7 +214,6 @@ export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<strin
if (forkedEntries.length === 0) {
await storage.getSessionInfo();
}
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
return toSession(info, new DefaultSessionTree(storage));
return toSession(storage, new DefaultSessionTree(storage));
}
}

View File

@@ -18,7 +18,6 @@ import type {
SessionTreeStorage,
ThinkingLevelChangeEntry,
} from "../types.js";
import { InMemorySessionTreeStorage } from "./memory-session-storage.js";
function generateId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
@@ -79,11 +78,11 @@ export function buildSessionContext(entries: SessionTreeEntry[]): SessionContext
return { messages, thinkingLevel, model };
}
export class DefaultSessionTree implements SessionTree {
private storage: SessionTreeStorage;
export class DefaultSessionTree<TInfo extends SessionInfo = SessionInfo> implements SessionTree {
private storage: SessionTreeStorage<TInfo>;
constructor(storage?: SessionTreeStorage) {
this.storage = storage ?? new InMemorySessionTreeStorage();
constructor(storage: SessionTreeStorage<TInfo>) {
this.storage = storage;
}
getLeafId(): Promise<string | null> {
@@ -107,7 +106,7 @@ export class DefaultSessionTree implements SessionTree {
return buildSessionContext(await this.getBranch());
}
getSessionInfo(): Promise<SessionInfo> {
getSessionInfo(): Promise<TInfo> {
return this.storage.getSessionInfo();
}

View File

@@ -160,16 +160,16 @@ export interface SessionContext {
export interface SessionInfo {
id: string;
createdAt: string;
parentSession?: string;
}
export interface CodingAgentSessionInfo extends SessionInfo {
projectCwd: string;
filePath?: string;
export interface JsonlSessionInfo extends SessionInfo {
cwd: string;
path: string;
parentSessionPath?: string;
}
export interface SessionTreeStorage {
getSessionInfo(): Promise<SessionInfo>;
export interface SessionTreeStorage<TInfo extends SessionInfo = SessionInfo> {
getSessionInfo(): Promise<TInfo>;
getLeafId(): Promise<string | null>;
setLeafId(leafId: string | null): Promise<void>;
appendEntry(entry: SessionTreeEntry): Promise<void>;
@@ -179,7 +179,6 @@ export interface SessionTreeStorage {
}
export interface SessionTree {
getSessionInfo(): Promise<SessionInfo>;
getLeafId(): Promise<string | null>;
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
getEntries(): Promise<SessionTreeEntry[]>;
@@ -213,21 +212,40 @@ export interface SessionTree {
}
export interface Session<TInfo extends SessionInfo = SessionInfo> {
info: TInfo;
storage: SessionTreeStorage<TInfo>;
tree: SessionTree;
}
export interface SessionRepo<TRef = string, TInfo extends SessionInfo = SessionInfo> {
create(options?: { id?: string; parentSession?: string }): Promise<Session<TInfo>>;
export interface SessionCreateOptions {
id?: string;
}
export interface SessionForkOptions {
entryId: string;
position?: "before" | "at";
id?: string;
}
export interface SessionRepo<
TRef = string,
TInfo extends SessionInfo = SessionInfo,
TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
> {
create(options?: TCreateOptions): Promise<Session<TInfo>>;
open(ref: TRef): Promise<Session<TInfo>>;
list(): Promise<Array<Session<TInfo>>>;
delete(ref: TRef): Promise<void>;
fork(ref: TRef, options: { entryId: string; position?: "before" | "at"; id?: string }): Promise<Session<TInfo>>;
fork(ref: TRef, options: SessionForkOptions): Promise<Session<TInfo>>;
}
export interface CodingAgentSessionRepo<TRef = string> extends SessionRepo<TRef, CodingAgentSessionInfo> {
listByCwd(cwd: string): Promise<Array<Session<CodingAgentSessionInfo>>>;
getMostRecentByCwd(cwd: string): Promise<Session<CodingAgentSessionInfo> | undefined>;
export interface JsonlSessionCreateOptions extends SessionCreateOptions {
parentSessionPath?: string;
}
export interface JsonlSessionRepo<TRef = string>
extends SessionRepo<TRef, JsonlSessionInfo, JsonlSessionCreateOptions> {
listByCwd(cwd: string): Promise<Array<Session<JsonlSessionInfo>>>;
getMostRecentByCwd(cwd: string): Promise<Session<JsonlSessionInfo> | undefined>;
}
export interface AgentHarnessPendingMutations {