fix(agent): reserve JSONL session destinations

Coordinate create and fork operations by cwd and session ID so only the first caller can publish a destination.

Split destination-reservation conformance from active-source snapshot coordination.
This commit is contained in:
David Brailovsky 2026-08-14 15:53:28 +02:00
parent 71026970a0
commit 95e3765ade
4 changed files with 123 additions and 70 deletions

View file

@ -51,6 +51,7 @@ export class JsonlSessionRepo
private readonly fileSystem: FileSystem;
private readonly sessionsRootInput: string;
private readonly now: () => number;
private readonly activeCreateDestinations = new Set<string>();
private rootPromise: Promise<string> | undefined;
private closed = false;
private closePromise: Promise<void> | undefined;
@ -64,45 +65,43 @@ export class JsonlSessionRepo
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
this.assertOpen();
const createdAt = this.now();
const id = options.id ?? uuidv7(createdAt);
const cwd = fileValue(
await this.fileSystem.absolutePath(options.cwd),
`Failed to resolve session cwd ${options.cwd}`,
);
// TODO: Prevent concurrent create calls from passing this asynchronous check for the same cwd and id.
if (await this.sessionIdExists(cwd, id)) throw new Error(`Session already exists: ${id}`);
const path = await this.createPath(cwd, createdAt, id);
const header: JsonlStorageHeader = {
v: JSONL_FORMAT_VERSION,
kind: "header",
id,
storageVersion: JSONL_STORAGE_VERSION,
createdAt,
cwd,
...(options.parentSessionId === undefined ? {} : { parentSessionId: options.parentSessionId }),
};
// TODO: do we want to make session creation atomic?
const storage = await JsonlStorage.create({ fileSystem: this.fileSystem, path, now: this.now }, header);
try {
await storage.commit({
writes: [
{ kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: null },
{
kind: "register",
op: "set",
namespace: "lane.state",
key: "main",
value: { currentOperationId: null, pendingNextRun: [] },
},
],
});
const info = fileValue(await this.fileSystem.fileInfo(path), `Failed to read session ${path}`);
return new StorageBackedSession(metadataFromHeader(header, path, info.mtimeMs), storage);
} catch (error) {
await storage.close().catch(() => undefined);
await this.fileSystem.remove(path, { force: true });
throw error;
}
const destination = await this.resolveCreateDestination(options.cwd, options.id, createdAt);
return this.claimCreateDestination(destination, async () => {
const { cwd, id } = destination;
if (await this.sessionIdExists(cwd, id)) throw new Error(`Session already exists: ${id}`);
const path = await this.createPath(cwd, createdAt, id);
const header: JsonlStorageHeader = {
v: JSONL_FORMAT_VERSION,
kind: "header",
id,
storageVersion: JSONL_STORAGE_VERSION,
createdAt,
cwd,
...(options.parentSessionId === undefined ? {} : { parentSessionId: options.parentSessionId }),
};
// TODO: do we want to make session creation atomic?
const storage = await JsonlStorage.create({ fileSystem: this.fileSystem, path, now: this.now }, header);
try {
await storage.commit({
writes: [
{ kind: "register", op: "set", namespace: "lane.leaf", key: "main", value: null },
{
kind: "register",
op: "set",
namespace: "lane.state",
key: "main",
value: { currentOperationId: null, pendingNextRun: [] },
},
],
});
const info = fileValue(await this.fileSystem.fileInfo(path), `Failed to read session ${path}`);
return new StorageBackedSession(metadataFromHeader(header, path, info.mtimeMs), storage);
} catch (error) {
await storage.close().catch(() => undefined);
await this.fileSystem.remove(path, { force: true });
throw error;
}
});
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
@ -142,36 +141,39 @@ export class JsonlSessionRepo
async fork(source: JsonlSessionMetadata, options: ForkOptions): Promise<Session<JsonlSessionMetadata>> {
this.assertOpen();
const createdAt = this.now();
const id = options.id ?? uuidv7(createdAt);
if (await this.sessionIdExists(source.cwd, id)) throw new Error(`Session already exists: ${id}`);
const destination = await this.resolveCreateDestination(source.cwd, options.id, createdAt);
return this.claimCreateDestination(destination, async () => {
const { cwd, id } = destination;
if (await this.sessionIdExists(cwd, id)) throw new Error(`Session already exists: ${id}`);
const sourceStorage = await this.loadStorage(source);
const sourceSnapshot = await sourceStorage.snapshot().finally(() => sourceStorage.close());
const snapshot = createForkSnapshot(sourceSnapshot, options);
const path = await this.createPath(source.cwd, createdAt, id);
const header: JsonlStorageHeader = {
v: JSONL_FORMAT_VERSION,
kind: "header",
id,
storageVersion: JSONL_STORAGE_VERSION,
createdAt,
cwd: source.cwd,
parentSessionId: source.id,
};
let storage: JsonlStorage | undefined;
try {
storage = await JsonlStorage.createFromSnapshot(
{ fileSystem: this.fileSystem, path, now: this.now },
header,
snapshot,
);
const info = fileValue(await this.fileSystem.fileInfo(path), `Failed to read session ${path}`);
return new StorageBackedSession(metadataFromHeader(header, path, info.mtimeMs), storage);
} catch (error) {
await storage?.close().catch(() => undefined);
await this.fileSystem.remove(path, { force: true });
throw error;
}
const sourceStorage = await this.loadStorage(source);
const sourceSnapshot = await sourceStorage.snapshot().finally(() => sourceStorage.close());
const snapshot = createForkSnapshot(sourceSnapshot, options);
const path = await this.createPath(cwd, createdAt, id);
const header: JsonlStorageHeader = {
v: JSONL_FORMAT_VERSION,
kind: "header",
id,
storageVersion: JSONL_STORAGE_VERSION,
createdAt,
cwd,
parentSessionId: source.id,
};
let storage: JsonlStorage | undefined;
try {
storage = await JsonlStorage.createFromSnapshot(
{ fileSystem: this.fileSystem, path, now: this.now },
header,
snapshot,
);
const info = fileValue(await this.fileSystem.fileInfo(path), `Failed to read session ${path}`);
return new StorageBackedSession(metadataFromHeader(header, path, info.mtimeMs), storage);
} catch (error) {
await storage?.close().catch(() => undefined);
await this.fileSystem.remove(path, { force: true });
throw error;
}
});
}
close(): Promise<void> {
@ -182,6 +184,30 @@ export class JsonlSessionRepo
return this.closePromise;
}
private async resolveCreateDestination(
cwdInput: string,
id: string | undefined,
createdAt: number,
): Promise<{ cwd: string; id: string }> {
const destinationId = id ?? uuidv7(createdAt);
const cwd = fileValue(await this.fileSystem.absolutePath(cwdInput), `Failed to resolve session cwd ${cwdInput}`);
return { cwd, id: destinationId };
}
private async claimCreateDestination<T>(
destination: { cwd: string; id: string },
operation: () => Promise<T>,
): Promise<T> {
const key = `${destination.cwd}\0${destination.id}`;
if (this.activeCreateDestinations.has(key)) throw new Error(`Session already exists: ${destination.id}`);
this.activeCreateDestinations.add(key);
try {
return await operation();
} finally {
this.activeCreateDestinations.delete(key);
}
}
private async listDirectory(directory: string, cwd?: string): Promise<JsonlSessionMetadata[]> {
if (!fileValue(await this.fileSystem.exists(directory), `Failed to check sessions directory ${directory}`)) {
return [];

View file

@ -476,8 +476,8 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
];
}
/** Creates fork cases that require destination reservation or active-source snapshot coordination. */
export function createSessionRepoForkCoordinationConformance<TMetadata extends SessionMetadata>(
/** Creates fork cases that require destination reservation across create and fork. */
export function createSessionRepoForkDestinationReservationConformance<TMetadata extends SessionMetadata>(
backendFactory: () => Promise<Pick<SessionRepo<TMetadata>, "create" | "fork">>,
onClose?: () => void | Promise<void>,
): readonly ConformanceCase[] {
@ -515,6 +515,16 @@ export function createSessionRepoForkCoordinationConformance<TMetadata extends S
await source.close();
},
),
];
}
/** Creates fork cases that require a snapshot boundary on an active source storage queue. */
export function createSessionRepoForkSourceSnapshotConformance<TMetadata extends SessionMetadata>(
backendFactory: () => Promise<Pick<SessionRepo<TMetadata>, "create" | "fork">>,
onClose?: () => void | Promise<void>,
): readonly ConformanceCase[] {
const factory = prepareRepoCaseFactory(backendFactory, onClose);
return [
createCase(
factory,
"fork coordination",
@ -555,6 +565,17 @@ export function createSessionRepoForkCoordinationConformance<TMetadata extends S
];
}
/** Creates every fork coordination case. */
export function createSessionRepoForkCoordinationConformance<TMetadata extends SessionMetadata>(
backendFactory: () => Promise<Pick<SessionRepo<TMetadata>, "create" | "fork">>,
onClose?: () => void | Promise<void>,
): readonly ConformanceCase[] {
return [
...createSessionRepoForkDestinationReservationConformance(backendFactory, onClose),
...createSessionRepoForkSourceSnapshotConformance(backendFactory, onClose),
];
}
/** Creates every fork conformance case. */
export function createSessionRepoForkConformance<TMetadata extends SessionMetadata>(
backendFactory: () => Promise<Pick<SessionRepo<TMetadata>, "create" | "list" | "fork">>,

View file

@ -21,6 +21,8 @@ export {
createSessionRepoForkBehaviorConformance,
createSessionRepoForkConformance,
createSessionRepoForkCoordinationConformance,
createSessionRepoForkDestinationReservationConformance,
createSessionRepoForkSourceSnapshotConformance,
createSessionRepoLifecycleConformance,
createSessionRepoMessageConformance,
createSessionRepoOwnershipConformance,

View file

@ -4,6 +4,7 @@ import { type JsonlSessionMetadata, JsonlSessionRepo } from "../../src/harness/s
import {
type ConformanceCase,
createSessionRepoForkBehaviorConformance,
createSessionRepoForkDestinationReservationConformance,
createSessionRepoLifecycleConformance,
createSessionRepoMessageConformance,
} from "../../src/harness/session/testing/index.ts";
@ -43,4 +44,7 @@ registerConformance("JsonlSessionRepo conformance", [
...createSessionRepoLifecycleConformance<JsonlSessionMetadata>(createConformanceRepo, () => jsonlRepo.close()),
...createSessionRepoMessageConformance<JsonlSessionMetadata>(createConformanceRepo, () => jsonlRepo.close()),
...createSessionRepoForkBehaviorConformance<JsonlSessionMetadata>(createConformanceRepo, () => jsonlRepo.close()),
...createSessionRepoForkDestinationReservationConformance<JsonlSessionMetadata>(createConformanceRepo, () =>
jsonlRepo.close(),
),
]);