refactor(agent): harden harness session semantics

This commit is contained in:
Mario Zechner
2026-05-16 00:32:16 +02:00
parent a8af0b5e99
commit 4f40f62b7b
23 changed files with 1112 additions and 873 deletions
@@ -6,9 +6,15 @@ import type {
JsonlSessionRepoApi,
Session,
} from "../types.js";
import { getOrThrow } from "../types.js";
import { SessionError, toError } from "../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
import {
createSessionId,
createTimestamp,
getEntriesToFork,
getFileSystemResultOrThrow,
toSession,
} from "./repo-utils.js";
type JsonlSessionRepoFileSystem = Pick<
FileSystem,
@@ -16,6 +22,7 @@ type JsonlSessionRepoFileSystem = Pick<
| "absolutePath"
| "joinPath"
| "readTextFile"
| "readTextLines"
| "writeFile"
| "appendFile"
| "listDir"
@@ -40,21 +47,28 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
private async getSessionsRoot(): Promise<string> {
if (!this.sessionsRoot) {
this.sessionsRoot = getOrThrow(await this.fs.absolutePath(this.sessionsRootInput));
this.sessionsRoot = getFileSystemResultOrThrow(
await this.fs.absolutePath(this.sessionsRootInput),
`Failed to resolve sessions root ${this.sessionsRootInput}`,
);
}
return this.sessionsRoot;
}
private async getSessionDir(cwd: string): Promise<string> {
return getOrThrow(await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]));
return getFileSystemResultOrThrow(
await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]),
`Failed to resolve session directory for ${cwd}`,
);
}
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
return getOrThrow(
return getFileSystemResultOrThrow(
await this.fs.joinPath([
await this.getSessionDir(cwd),
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
]),
`Failed to resolve session file path for ${sessionId}`,
);
}
@@ -62,7 +76,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
getFileSystemResultOrThrow(
await this.fs.createDir(sessionDir, { recursive: true }),
`Failed to create session directory ${sessionDir}`,
);
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
cwd: options.cwd,
@@ -73,8 +90,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (!getOrThrow(await this.fs.exists(metadata.path))) {
throw new Error(`Session not found: ${metadata.path}`);
if (
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
) {
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
return toSession(storage);
@@ -84,15 +103,19 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!getOrThrow(await this.fs.exists(dir))) continue;
const files = getOrThrow(await this.fs.listDir(dir)).filter(
(file) => file.kind !== "directory" && file.name.endsWith(".jsonl"),
);
if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) {
continue;
}
const files = getFileSystemResultOrThrow(
await this.fs.listDir(dir),
`Failed to list sessions in ${dir}`,
).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl"));
for (const file of files) {
try {
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
} catch {
// Ignore invalid session files when listing a directory.
} catch (error) {
const cause = toError(error);
if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause;
}
}
}
@@ -101,7 +124,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
getOrThrow(await this.fs.remove(metadata.path, { force: true }));
getFileSystemResultOrThrow(
await this.fs.remove(metadata.path, { force: true }),
`Failed to delete session ${metadata.path}`,
);
}
async fork(
@@ -113,7 +139,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
getFileSystemResultOrThrow(
await this.fs.createDir(sessionDir, { recursive: true }),
`Failed to create session directory ${sessionDir}`,
);
const storage = await JsonlSessionStorage.create(
this.fs,
await this.createSessionFilePath(options.cwd, id, createdAt),
@@ -131,8 +160,18 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
private async listSessionDirs(): Promise<string[]> {
const sessionsRoot = await this.getSessionsRoot();
if (!getOrThrow(await this.fs.exists(sessionsRoot))) return [];
const entries = getOrThrow(await this.fs.listDir(sessionsRoot));
if (
!getFileSystemResultOrThrow(
await this.fs.exists(sessionsRoot),
`Failed to check sessions root ${sessionsRoot}`,
)
) {
return [];
}
const entries = getFileSystemResultOrThrow(
await this.fs.listDir(sessionsRoot),
`Failed to list sessions root ${sessionsRoot}`,
);
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
}
}
@@ -1,8 +1,9 @@
import type { FileSystem, JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
import { getOrThrow } from "../types.js";
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.js";
import { SessionError, toError } from "../types.js";
import { getFileSystemResultOrThrow } from "./repo-utils.js";
import { uuidv7 } from "./uuid.js";
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "writeFile" | "appendFile">;
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">;
interface SessionHeader {
type: "session";
@@ -39,6 +40,76 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
return uuidv7();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause);
}
function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError {
return new SessionError(
"invalid_entry",
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
cause,
);
}
function parseHeaderLine(line: string, filePath: string): SessionHeader {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
}
if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id");
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidSession(filePath, "session header is missing timestamp");
}
if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd");
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
throw invalidSession(filePath, "session header parentSession must be a string");
}
return {
type: "session",
version: 3,
id: parsed.id,
timestamp: parsed.timestamp,
cwd: parsed.cwd,
parentSession: parsed.parentSession,
};
}
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
}
if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
}
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
}
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
}
return parsed as unknown as SessionTreeEntry;
}
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
return {
id: header.id,
@@ -53,17 +124,13 @@ export async function loadJsonlSessionMetadata(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<JsonlSessionMetadata> {
const content = getOrThrow(await fs.readTextFile(filePath));
for (const line of content.split("\n")) {
if (!line.trim()) break;
try {
const header = JSON.parse(line) as SessionHeader;
return headerToSessionMetadata(header, filePath);
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
}
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
const lines = getFileSystemResultOrThrow(
await fs.readTextLines(filePath, { maxLines: 1 }),
`Failed to read session header ${filePath}`,
);
const line = lines[0];
if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
throw invalidSession(filePath, "missing session header");
}
async function loadJsonlStorage(
@@ -74,29 +141,19 @@ async function loadJsonlStorage(
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = getOrThrow(await fs.readTextFile(filePath));
const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`);
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
}
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`);
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(lines[0]!, filePath);
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
}
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i]!, filePath, i + 1);
entries.push(entry);
leafId = leafIdAfterEntry(entry);
}
return { header, entries, leafId };
}
@@ -148,7 +205,10 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
getOrThrow(await fs.writeFile(filePath, `${JSON.stringify(header)}\n`));
getFileSystemResultOrThrow(
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
`Failed to create session ${filePath}`,
);
return new JsonlSessionStorage(fs, filePath, header, [], null);
}
@@ -157,13 +217,29 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
}
async getLeafId(): Promise<string | null> {
if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) {
throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`);
}
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.currentLeafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session leaf ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.currentLeafId = leafId;
}
@@ -172,11 +248,14 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
getOrThrow(await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`));
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session entry ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = entry.id;
this.currentLeafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
@@ -197,9 +276,13 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}
@@ -1,4 +1,4 @@
import type { Session, SessionMetadata, SessionRepo } from "../types.js";
import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.js";
import { InMemorySessionStorage } from "./memory-storage.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
@@ -19,7 +19,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
const session = this.sessions.get(metadata.id);
if (!session) {
throw new Error(`Session not found: ${metadata.id}`);
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
}
return session;
}
@@ -42,8 +42,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
};
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries, leafId });
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
const session = toSession(storage);
this.sessions.set(metadata.id, session);
return session;
@@ -1,4 +1,10 @@
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
import {
type LeafEntry,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.js";
import { uuidv7 } from "./uuid.js";
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
@@ -27,36 +33,55 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
return uuidv7();
}
export class InMemorySessionStorage implements SessionStorage {
private readonly metadata: SessionMetadata;
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
implements SessionStorage<TMetadata>
{
private readonly metadata: TMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private leafId: string | null;
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; metadata?: SessionMetadata }) {
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
this.entries = options?.entries ? [...options.entries] : [];
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(this.entries);
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
this.leafId = null;
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new Error(`Entry ${this.leafId} not found`);
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
this.metadata = options?.metadata ?? { id: uuidv7(), createdAt: new Date().toISOString() };
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
}
async getMetadata(): Promise<SessionMetadata> {
async getMetadata(): Promise<TMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
return this.leafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = leafId;
}
@@ -68,7 +93,7 @@ export class InMemorySessionStorage implements SessionStorage {
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.leafId = entry.id;
this.leafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
@@ -89,9 +114,13 @@ export class InMemorySessionStorage implements SessionStorage {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}
@@ -1,4 +1,11 @@
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
import {
type FileError,
type Result,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.js";
import { Session } from "./session.js";
import { uuidv7 } from "./uuid.js";
@@ -14,6 +21,14 @@ export function toSession<TMetadata extends SessionMetadata>(storage: SessionSto
return new Session(storage);
}
export function getFileSystemResultOrThrow<TValue>(result: Result<TValue, FileError>, message: string): TValue {
if (!result.ok) {
const code = result.error.code === "not_found" ? "not_found" : "storage";
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
}
return result.value;
}
export async function getEntriesToFork(
storage: SessionStorage,
options: { entryId?: string; position?: "before" | "at" },
@@ -21,14 +36,14 @@ export async function getEntriesToFork(
if (!options.entryId) return storage.getEntries();
const target = await storage.getEntry(options.entryId);
if (!target) {
throw new Error(`Entry ${options.entryId} not found`);
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`);
}
let effectiveLeafId: string | null;
if ((options.position ?? "before") === "at") {
effectiveLeafId = target.id;
} else {
if (target.type !== "message" || target.message.role !== "user") {
throw new Error(`Entry ${options.entryId} is not a user message`);
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`);
}
effectiveLeafId = target.parentId;
}
@@ -16,6 +16,7 @@ import type {
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../types.js";
import { SessionError } from "../types.js";
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
@@ -206,7 +207,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
if (!(await this.storage.getEntry(targetId))) {
throw new Error(`Entry ${targetId} not found`);
throw new SessionError("not_found", `Entry ${targetId} not found`);
}
return this.appendTypedEntry({
type: "label",
@@ -233,7 +234,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
summary?: { summary: string; details?: unknown; fromHook?: boolean },
): Promise<string | undefined> {
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
throw new Error(`Entry ${entryId} not found`);
throw new SessionError("not_found", `Entry ${entryId} not found`);
}
await this.storage.setLeafId(entryId);
if (!summary) return undefined;