fix(agent): require configured lanes for branch forks

Reject data-only source branches, preserve tree-scope support for them, and update fork fixtures to provide complete lane configuration and state.
This commit is contained in:
David Brailovsky 2026-09-01 15:18:05 +02:00
parent f2eae920c6
commit 1081eb2126
7 changed files with 79 additions and 22 deletions

View file

@ -151,6 +151,9 @@ function validateForkSourceSnapshot(
if ((configuration === undefined) !== (state === undefined)) {
throw new Error(`Source session branch ${JSON.stringify(tip.address.key)} has incomplete lane state`);
}
if (options.scope === "branch" && tip.address.key === options.branch && configuration === undefined) {
throw new Error(`Source branch ${JSON.stringify(options.branch)} is not a configured AgentLane`);
}
if (
(source.entriesComplete !== false || options.scope === "tree") &&
tip.value !== null &&

View file

@ -1,6 +1,6 @@
import { BACKGROUND_CONTEXT } from "../../../context.ts";
import type { SessionMetadata, SessionRepo } from "../../types.ts";
import { branchTip, setValue } from "../../values.ts";
import { branchTip, laneConfig, laneState, setValue } from "../../values.ts";
import { STORAGE_BENCHMARK_DATASETS, type StorageBenchmarkDataset } from "./datasets.ts";
import { generateStorageBenchmarkSeedTransactions } from "./storage.ts";
@ -142,7 +142,19 @@ export async function seedSessionRepoForkBenchmark(
await session.mutate((mutator) => mutator.commit(transaction, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
}
await session.mutate(
(mutator) => mutator.commit([setValue(branchTip("main"), dataset.tipId)], BACKGROUND_CONTEXT),
(mutator) =>
mutator.commit(
[
setValue(branchTip("main"), dataset.tipId),
setValue(laneConfig("main"), {
model: { provider: "benchmark", modelId: "benchmark" },
thinkingLevel: "off",
activeToolNames: [],
}),
setValue(laneState("main"), { currentOperationId: null, lastOperationId: null, inbox: [] }),
],
BACKGROUND_CONTEXT,
),
BACKGROUND_CONTEXT,
);
return session.metadata;

View file

@ -334,6 +334,21 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
});
await Promise.all([source.close(BACKGROUND_CONTEXT), fork.close(BACKGROUND_CONTEXT)]);
}),
createCase(factory, "forks", "rejects a data-only branch and releases its destination id", async ({ repo }) => {
const source = await repo.create({ id: "source" }, BACKGROUND_CONTEXT);
await source.createBranch("data", null, BACKGROUND_CONTEXT);
await rejects(
repo.fork(source.metadata, { id: "destination", scope: "branch", branch: "data" }, BACKGROUND_CONTEXT),
);
deepStrictEqual(
(await repo.list(undefined, BACKGROUND_CONTEXT)).map(({ id }) => id),
["source"],
);
const destination = await repo.create({ id: "destination" }, BACKGROUND_CONTEXT);
await Promise.all([source.close(BACKGROUND_CONTEXT), destination.close(BACKGROUND_CONTEXT)]);
}),
createCase(
factory,
"forks",
@ -571,6 +586,8 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
customType: "root",
}),
setValue(branchTip("main"), ROOT_ID),
setValue(laneConfig("main"), configuration),
setValue(laneState("main"), idleLaneState),
setValue(applicationValue, "excluded"),
appendList(applicationList, "excluded"),
],
@ -586,8 +603,8 @@ export function createSessionRepoForkBehaviorConformance<TMetadata extends Sessi
BACKGROUND_CONTEXT,
);
strictEqual(await getBranchTip(fork), ROOT_ID);
strictEqual(await fork.getValue(laneConfig("main"), BACKGROUND_CONTEXT), undefined);
strictEqual(await fork.getValue(laneState("main"), BACKGROUND_CONTEXT), undefined);
deepStrictEqual((await fork.getValue(laneConfig("main"), BACKGROUND_CONTEXT))?.value, configuration);
deepStrictEqual((await fork.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value, idleLaneState);
strictEqual(await fork.getValue(applicationValue, BACKGROUND_CONTEXT), undefined);
deepStrictEqual(await fork.readList(applicationList, undefined, BACKGROUND_CONTEXT), []);
await fork.close(BACKGROUND_CONTEXT);
@ -713,6 +730,8 @@ export function createSessionRepoForkSourceSnapshotConformance<TMetadata extends
customType: "first",
}),
setValue(branchTip("main"), ROOT_ID),
setValue(laneConfig("main"), configuration),
setValue(laneState("main"), idleLaneState),
setValue(sessionName, "first name"),
setValue(entryLabel(ROOT_ID), "first label"),
],

View file

@ -560,8 +560,12 @@ export interface SessionCreateOptions {
export type ForkOptions =
| {
/**
* Copy one path from a complete configured source AgentLane under the same
* Branch name, with copied configuration and fresh idle lane state.
*/
scope: "branch";
/** Source Branch to copy under the same name in the destination. */
/** Source Branch to copy. */
branch: string;
/** Entry on the source Branch's current tip ancestry. Defaults to the current tip. */
entryId?: string;

View file

@ -276,11 +276,7 @@ describe("JSONL v3 migration", () => {
it("forks a closed source into a complete v4 destination without rewriting it", async () => {
const { path, content, metadata } = await writeForkFixture();
const fork = await repo.fork(
metadata,
{ id: "closed-fork", scope: "branch", branch: "main" },
BACKGROUND_CONTEXT,
);
const fork = await repo.fork(metadata, { id: "closed-fork", scope: "tree" }, BACKGROUND_CONTEXT);
expect(getOrThrow(await fileSystem.readTextFile(path, BACKGROUND_CONTEXT))).toBe(content);
expect(fork.metadata.id).toBe("closed-fork");
@ -313,11 +309,7 @@ describe("JSONL v3 migration", () => {
const sourceEntries = await source.findEntries({ order: "asc" }, BACKGROUND_CONTEXT);
const sourceStats = await source.getStats(BACKGROUND_CONTEXT);
const fork = await repo.fork(
metadata,
{ id: "open-fork", scope: "branch", branch: "main" },
BACKGROUND_CONTEXT,
);
const fork = await repo.fork(metadata, { id: "open-fork", scope: "tree" }, BACKGROUND_CONTEXT);
const forkEntries = await expectForkedState(fork);
expect(fork.metadata.id).toBe("open-fork");

View file

@ -98,6 +98,16 @@ describe("MemorySessionRepo metadata", () => {
customType: "child",
}),
storedValues.setValue(storedValues.branchTip("main"), childId),
storedValues.setValue(storedValues.laneConfig("main"), {
model: { provider: "test", modelId: "test" },
thinkingLevel: "off",
activeToolNames: [],
}),
storedValues.setValue(storedValues.laneState("main"), {
currentOperationId: null,
lastOperationId: null,
inbox: [],
}),
],
BACKGROUND_CONTEXT,
);

View file

@ -8,6 +8,17 @@ import { describe, expect, it } from "vitest";
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteStatement } from "../src/index.ts";
import { createNodeSqliteFactory, SqliteSessionRepo, sql } from "../src/index.ts";
const TEST_LANE_CONFIGURATION = {
model: { provider: "test", modelId: "test" },
thinkingLevel: "off",
activeToolNames: [],
} satisfies storedValues.LaneConfiguration;
const IDLE_LANE_STATE = {
currentOperationId: null,
lastOperationId: null,
inbox: [],
} satisfies storedValues.LaneState;
async function withTempDir<T>(run: (directory: string) => Promise<T>): Promise<T> {
const directory = await mkdtemp(join(tmpdir(), "pi-sqlite-session-"));
try {
@ -202,23 +213,23 @@ function commitLaterSourceState(db: SqliteDatabase): void {
const label = storedValues.entryLabel("root");
db.transaction(() => {
sql`INSERT INTO entries (session_id, id, parent_id, seq, type, custom_type, timestamp, payload)
VALUES (${"source"}, ${"child"}, ${"root"}, ${5}, ${"message"}, ${null}, ${2}, ${JSON.stringify({ message: { role: "user", content: "after", timestamp: 2 } })})`.run(
VALUES (${"source"}, ${"child"}, ${"root"}, ${7}, ${"message"}, ${null}, ${2}, ${JSON.stringify({ message: { role: "user", content: "after", timestamp: 2 } })})`.run(
db,
);
sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type)
VALUES (${"source"}, ${"root"}, ${"child"}, ${5}, ${"message"})`.run(db);
sql`UPDATE branch_meta SET tip_entry_id = ${"child"}, tip_seq = ${5}
VALUES (${"source"}, ${"root"}, ${"child"}, ${7}, ${"message"})`.run(db);
sql`UPDATE branch_meta SET tip_entry_id = ${"child"}, tip_seq = ${7}
WHERE session_id = ${"source"} AND branch_id = ${"root"}`.run(db);
for (const [namespace, key, seq, value] of [
[tip.namespace, tip.key, 6, "child"],
[name.namespace, name.key, 7, "after"],
[label.namespace, label.key, 8, "after-label"],
[tip.namespace, tip.key, 8, "child"],
[name.namespace, name.key, 9, "after"],
[label.namespace, label.key, 10, "after-label"],
] as const) {
sql`INSERT INTO scalar_values (session_id, namespace, key, seq, value)
VALUES (${"source"}, ${namespace}, ${key}, ${seq}, ${JSON.stringify(value)})
ON CONFLICT(session_id, namespace, key) DO UPDATE SET seq = excluded.seq, value = excluded.value`.run(db);
}
sql`UPDATE sessions SET message_count = ${1}, next_seq = ${9} WHERE id = ${"source"}`.run(db);
sql`UPDATE sessions SET message_count = ${1}, next_seq = ${11} WHERE id = ${"source"}`.run(db);
});
}
@ -558,6 +569,8 @@ describe("SqliteSessionRepo", () => {
message: { role: "user", content: "child", timestamp: 1 },
}),
storedValues.setValue(storedValues.branchTip("main"), "child"),
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
sessionWrites.insertUsage({
id: "usage",
adjustment: false,
@ -756,6 +769,8 @@ describe("SqliteSessionRepo", () => {
[
sessionWrites.insertEntry({ id: "left-root", parentId: null, type: "custom", customType: "left" }),
storedValues.setValue(storedValues.branchTip("main"), "left-root"),
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
storedValues.setValue(storedValues.sessionName, "left"),
],
BACKGROUND_CONTEXT,
@ -813,6 +828,8 @@ describe("SqliteSessionRepo", () => {
[
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
storedValues.setValue(storedValues.branchTip("main"), "root"),
storedValues.setValue(storedValues.laneConfig("main"), TEST_LANE_CONFIGURATION),
storedValues.setValue(storedValues.laneState("main"), IDLE_LANE_STATE),
storedValues.setValue(storedValues.sessionName, "before"),
storedValues.setValue(storedValues.entryLabel("root"), "before-label"),
],