feat(agent): separate sessions, branches, and lanes

This commit is contained in:
Mario Zechner 2026-08-25 16:35:13 +02:00
parent cbaec01048
commit 423e20cb78
82 changed files with 3933 additions and 4299 deletions

View file

@ -1,7 +1,7 @@
import { access, mkdir, open as openFile, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import type { Context, Entry, ForkOptions, SessionCreateOptions, StoredValue } from "@earendil-works/pi-agent-core";
import { createForkSnapshot, laneLeaf, StorageBackedSession } from "@earendil-works/pi-agent-core";
import { branchTip, createForkSnapshot, StorageBackedSession } from "@earendil-works/pi-agent-core";
import { uuidv7 } from "@earendil-works/pi-ai";
import { applyInitialSchema } from "./migrations.ts";
import { appendEntryToBranchIndex, scanBranchEntries } from "./session/branch-entries.ts";
@ -15,7 +15,7 @@ import {
readSessionRow,
type SqliteSessionMetadata,
} from "./session/session-row.ts";
import { insertInitialMainLaneValues, readAllScalarValueRows, setScalarValueRow } from "./session/values.ts";
import { readAllScalarValueRows, setScalarValueRow } from "./session/values.ts";
import {
claimWriterLease,
readWriterLease,
@ -33,7 +33,7 @@ export const SQLITE_SESSION_EXTENSION = ".sqlite";
const DEFAULT_WRITER_LEASE_MS = 30_000;
const WRITER_LEASE_RENEW_INTERVAL_MS = DEFAULT_WRITER_LEASE_MS / 2;
const FIRST_AVAILABLE_COMMIT_SEQ = 3;
const FIRST_AVAILABLE_COMMIT_SEQ = 1;
export type SqliteSessionCreateOptions = SessionCreateOptions;
@ -98,12 +98,12 @@ function readForkSourceEntries(
options: ForkOptions,
): Entry[] {
if (options.scope === "tree") return readSourceEntries(db, sessionId);
const mainAddress = laneLeaf("main");
const mainLeaf = scalarValues.find(
const mainAddress = branchTip("main");
const mainTip = scalarValues.find(
(stored) => stored.address.namespace === mainAddress.namespace && stored.address.key === mainAddress.key,
) as StoredValue<string | null> | undefined;
if (mainLeaf === undefined) throw new Error("Source session is missing main lane");
const requested = options.entryId ?? mainLeaf.value;
if (mainTip === undefined) throw new Error("Source session is missing main branch");
const requested = options.entryId ?? mainTip.value;
return requested === null ? [] : scanBranchEntries(db, sessionId, { start: requested, order: "oldestFirst" });
}
@ -188,7 +188,6 @@ export class SqliteSessionRepo {
lease = activeDb.transaction(() => {
if (hasSessionRow(activeDb, id)) throw new Error(`SQLite session already exists: ${id}`);
insertSessionRow(activeDb, metadata, SQLITE_STORAGE_VERSION, FIRST_AVAILABLE_COMMIT_SEQ);
insertInitialMainLaneValues(activeDb, id);
return claimWriterLease(activeDb, id, uuidv7(this.now()), this.now(), DEFAULT_WRITER_LEASE_MS);
});
initialized = true;

View file

@ -1,18 +1,14 @@
import type {
AgentMessage,
BranchScan,
Branch,
Context,
Entry,
EntryQuery,
JsonValue,
LaneConfiguration,
ListElement,
ListReadOptions,
Session,
SessionMutation,
SessionMutator,
SessionMutationCallback,
SessionStats,
SessionTree,
StorageBranchScan,
StoredValue,
Value,
@ -60,22 +56,65 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
this.renewalTimer.unref?.();
}
beginMutation(lane: string, context: Context): Promise<SessionMutation> {
return this.admit(() => this.session.beginMutation(lane, context));
async beginMutation(context: Context): Promise<SessionMutation> {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
this.admitted.add(finished);
let source: SessionMutation;
try {
source = await this.admit(() => this.session.beginMutation(context));
} catch (error) {
this.admitted.delete(finished);
resolveFinished();
throw error;
}
if (this.state !== "open") {
await source.end(context);
this.admitted.delete(finished);
resolveFinished();
throw this.closedError;
}
let ended = false;
return {
commit: (writes, commitContext) => source.commit(writes, commitContext),
end: async (endContext) => {
try {
await source.end(endContext);
} finally {
if (!ended) {
ended = true;
this.admitted.delete(finished);
resolveFinished();
}
}
},
getEntries: (ids, readContext) => source.getEntries(ids, readContext),
getValue: (address, readContext) => source.getValue(address, readContext),
scanValues: (prefix, readContext) => source.scanValues(prefix, readContext),
readList: (address, options, readContext) => source.readList(address, options, readContext),
scanBranch: (query, readContext) => source.scanBranch(query, readContext),
};
}
mutate<T>(
lane: string,
mutation: (mutator: SessionMutator, context: Context) => T | Promise<T>,
context: Context,
): Promise<T> {
return this.admit(() => this.session.mutate(lane, mutation, context));
mutate<T>(mutation: SessionMutationCallback<T>, context: Context): Promise<T> {
return this.admit(() =>
this.session.mutate((mutator, mutationContext) => {
if (this.state !== "open") throw this.closedError;
return mutation(mutator, mutationContext);
}, context),
);
}
getEntries(ids: string[], context: Context): Promise<Map<string, Entry>> {
return this.admit(() => this.session.getEntries(ids, context));
}
getEntry(id: string, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.getEntry(id, context));
}
getValue<T>(address: Value<T>, context: Context): Promise<StoredValue<T> | undefined> {
return this.admit(() => this.session.getValue(address, context));
}
@ -96,58 +135,35 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
return this.admit(() => this.session.scanBranch(query, context));
}
view(lane: string): SessionTree {
const view = this.session.view(lane);
return {
getLeafId: (context) => this.admit(() => view.getLeafId(context)),
getEntry: (id, context) => this.admit(() => view.getEntry(id, context)),
getStats: (context) => this.admit(() => view.getStats(context)),
getValue: (address, context) => this.admit(() => view.getValue(address, context)),
scanValues: (prefix, context) => this.admit(() => view.scanValues(prefix, context)),
readList: (address, options, context) => this.admit(() => view.readList(address, options, context)),
setValue: (address, next, context) => this.admit(() => view.setValue(address, next, context)),
deleteValue: (address, context) => this.admit(() => view.deleteValue(address, context)),
appendList: (address, element, context) => this.admit(() => view.appendList(address, element, context)),
deleteList: (address, context) => this.admit(() => view.deleteList(address, context)),
getName: (context) => this.admit(() => view.getName(context)),
setName: (name, context) => this.admit(() => view.setName(name, context)),
getLabel: (targetId, context) => this.admit(() => view.getLabel(targetId, context)),
setLabel: (targetId, label, context) => this.admit(() => view.setLabel(targetId, label, context)),
findEntries: (query, context) => this.admit(() => view.findEntries(query, context)),
findEntry: (query, context) => this.admit(() => view.findEntry(query, context)),
findEntriesOnBranch: (query, context) => this.admit(() => view.findEntriesOnBranch(query, context)),
findEntryOnBranch: (query, context) => this.admit(() => view.findEntryOnBranch(query, context)),
appendMessage: (message, context) => this.admit(() => view.appendMessage(message, context)),
appendCustomEntry: (customType, data, context) =>
this.admit(() => view.appendCustomEntry(customType, data, context)),
};
}
createLane(
name: string,
at: string | null,
configuration: LaneConfiguration,
onCommitted: ((context: Context) => void | Promise<void>) | undefined,
context: Context,
): Promise<SessionTree> {
return this.admit(async () => {
await this.session.createLane(name, at, configuration, onCommitted, context);
return this.view(name);
});
}
getLeafId(context: Context): Promise<string | null> {
return this.admit(() => this.session.getLeafId(context));
}
getEntry(id: string, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.getEntry(id, context));
}
getStats(context: Context): Promise<SessionStats> {
return this.admit(() => this.session.getStats(context));
}
getName(context: Context): Promise<string | undefined> {
return this.admit(() => this.session.getName(context));
}
getLabel(targetId: string, context: Context): Promise<string | undefined> {
return this.admit(() => this.session.getLabel(targetId, context));
}
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
return this.admit(() => this.session.findEntries(query, context));
}
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.findEntry(query, context));
}
async branch(name: string, context: Context): Promise<Branch | undefined> {
const branch = await this.admit(() => this.session.branch(name, context));
return branch === undefined ? undefined : this.wrapBranch(branch);
}
async createBranch(name: string, at: string | null, context: Context): Promise<Branch> {
return this.wrapBranch(await this.admit(() => this.session.createBranch(name, at, context)));
}
setValue<T>(address: Value<T>, next: NoInfer<T>, context: Context): Promise<void> {
return this.admit(() => this.session.setValue(address, next, context));
}
@ -164,46 +180,14 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
return this.admit(() => this.session.deleteList(address, context));
}
getName(context: Context): Promise<string | undefined> {
return this.admit(() => this.session.getName(context));
}
setName(name: string | undefined, context: Context): Promise<void> {
return this.admit(() => this.session.setName(name, context));
}
getLabel(targetId: string, context: Context): Promise<string | undefined> {
return this.admit(() => this.session.getLabel(targetId, context));
}
setLabel(targetId: string, label: string | undefined, context: Context): Promise<void> {
return this.admit(() => this.session.setLabel(targetId, label, context));
}
findEntries(query: EntryQuery | undefined, context: Context): Promise<Entry[]> {
return this.admit(() => this.session.findEntries(query, context));
}
findEntry(query: EntryQuery | undefined, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.findEntry(query, context));
}
findEntriesOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry[]> {
return this.admit(() => this.session.findEntriesOnBranch(query, context));
}
findEntryOnBranch(query: BranchScan | undefined, context: Context): Promise<Entry | undefined> {
return this.admit(() => this.session.findEntryOnBranch(query, context));
}
appendMessage(message: AgentMessage, context: Context): Promise<string> {
return this.admit(() => this.session.appendMessage(message, context));
}
appendCustomEntry(customType: string, data: JsonValue | undefined, context: Context): Promise<string> {
return this.admit(() => this.session.appendCustomEntry(customType, data, context));
}
close(context: Context): Promise<void> {
if (this.closePromise !== undefined) return this.closePromise;
this.state = "closing";
@ -221,6 +205,18 @@ export class SqliteOpenSession implements Session<SqliteSessionMetadata> {
return this.closePromise;
}
private wrapBranch(branch: Branch): Branch {
return {
name: branch.name,
getTipId: (context) => this.admit(() => branch.getTipId(context)),
findEntries: (query, context) => this.admit(() => branch.findEntries(query, context)),
findEntry: (query, context) => this.admit(() => branch.findEntry(query, context)),
appendMessage: (message, context) => this.admit(() => branch.appendMessage(message, context)),
appendCustomEntry: (customType, data, context) =>
this.admit(() => branch.appendCustomEntry(customType, data, context)),
};
}
private admit<T>(operation: () => Promise<T>): Promise<T> {
if (this.state !== "open") return Promise.reject(this.closedError);
if (this.leaseError !== undefined) return Promise.reject(this.leaseError);

View file

@ -1,11 +1,8 @@
import {
type ListElement,
type ListReadOptions,
laneLeaf,
laneState,
resolveListReadOptions,
type StoredValue,
setValue,
type Value,
type ValueList,
value,
@ -25,17 +22,6 @@ export interface ListValueRow {
value: string;
}
export function insertInitialMainLaneValues(db: SqliteDatabase, sessionId: string): void {
const writes = [
setValue(laneLeaf("main"), null),
setValue(laneState("main"), { currentOperationId: null, pendingNextRun: [] }),
];
for (const [index, write] of writes.entries()) {
if (write.op !== "set") throw new Error("Expected initial scalar value write");
setScalarValueRow(db, sessionId, write.namespace, write.key, index + 1, write.value);
}
}
export function setScalarValueRow(
db: SqliteDatabase,
sessionId: string,

View file

@ -17,7 +17,7 @@ import type {
ValueList,
Write,
} from "@earendil-works/pi-agent-core";
import { laneLeaf, prepareStorageCommit } from "@earendil-works/pi-agent-core";
import { branchTip, prepareStorageCommit } from "@earendil-works/pi-agent-core";
import { appendEntryToBranchIndex, scanBranchEntries, scanBranchEntryStructures } from "./session/branch-entries.ts";
import { decodeEntryRow, EntryRowWriter, readAllEntryRows, readEntryRows, scanEntryRows } from "./session/entries.ts";
import { advanceNextSeq, readNextSeq } from "./session/session-sequences.ts";
@ -154,12 +154,12 @@ export class SqliteStorage implements Storage {
private readSnapshotEntries(options: ForkOptions, scalarValues: readonly StoredValue<unknown>[]): Entry[] {
if (options.scope === "tree") return readAllEntryRows(this.db, this.sessionId).map(decodeEntryRow);
const mainAddress = laneLeaf("main");
const mainLeaf = scalarValues.find(
const mainAddress = branchTip("main");
const mainTip = scalarValues.find(
(stored) => stored.address.namespace === mainAddress.namespace && stored.address.key === mainAddress.key,
) as StoredValue<string | null> | undefined;
if (mainLeaf === undefined) throw new Error("Source session is missing main lane");
const requested = options.entryId ?? mainLeaf.value;
if (mainTip === undefined) throw new Error("Source session is missing main branch");
const requested = options.entryId ?? mainTip.value;
return requested === null
? []
: scanBranchEntries(this.db, this.sessionId, { start: requested, order: "oldestFirst" });