From 5b86abfd52b976c9e23c7cce2d2e8edc0ca5acbe Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Thu, 8 Oct 2026 13:28:19 -0600 Subject: [PATCH 1/3] feat(profiles): add session profile controller lifecycle seams Adds captureForkEvidence, detach and attach so failed-append quarantine and copied-path uncertainty survive reload and fork without restoring unwritten activation or transferring callbacks. Refs #1064 --- lib/session-profile-append-controller.ts | 358 +++++++++++++- ...ion-profile-append-controller-fork.test.ts | 458 ++++++++++++++++++ ...ssion-profile-append-controller-pi.test.ts | 43 ++ .../session-profile-append-controller.test.ts | 116 +++++ 4 files changed, 962 insertions(+), 13 deletions(-) create mode 100644 tests/session-profile-append-controller-fork.test.ts diff --git a/lib/session-profile-append-controller.ts b/lib/session-profile-append-controller.ts index 9fbc5479c..679f66906 100644 --- a/lib/session-profile-append-controller.ts +++ b/lib/session-profile-append-controller.ts @@ -1,4 +1,4 @@ -// Observable append authority; no SDK, lifecycle seams or consumers. +// Observable append authority and explicit lifecycle seams; no SDK or consumers. import { readFileSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { @@ -34,6 +34,9 @@ type Scope = { // belongs to this controller and its enclosing scopes map. uncertain: boolean; certified: Map; + // Copied uncertainty is not evidence of a failed append and is never excluded + // through the reader's known-failed-ID seam. + untrusted: Set; pending?: Entry; }; /** Closed reasons the controller adds on top of the disk reader's own. */ @@ -60,6 +63,23 @@ export type SessionProfileAppendOutcome = { export interface SessionProfileAppendOptions { readFile?: (path: string) => string; } +export interface SessionProfileForkView extends SessionProfileSource { + getBranch(fromId?: string): readonly unknown[]; + getEntry(id: string): unknown; + getHeader(): unknown; +} +export type SessionProfileForkTarget = { + entryId: string; + position: "before" | "at"; +}; +declare const forkProof: unique symbol; +export type SessionProfileForkPreparation = { + readonly [forkProof]: "preparation"; +}; +export type SessionProfileForkHandoff = { readonly [forkProof]: "handoff" }; +export type SessionProfileForkEvidence = + | { status: "captured"; evidence: SessionProfileForkPreparation } + | { status: "indeterminate"; reason: string }; export interface SessionProfileAttachment { bind( name: string, @@ -68,6 +88,59 @@ export interface SessionProfileAttachment { clear(): SessionProfileAppendOutcome; refresh(): SessionProfileAppendOutcome; } +export type SessionProfileAttachContext = { + reason: "startup" | "reload" | "new" | "resume" | "fork"; + previousSessionFile?: string; + fork?: { + handoff: SessionProfileForkHandoff; + publicView: SessionProfileForkView; + }; +}; +export type SessionProfileDetachContext = { + reason: "reload" | "new" | "resume" | "fork" | "quit"; + targetSessionFile?: string; + preparation?: SessionProfileForkPreparation; +}; +type ForkSnapshot = { + owner: object; + generation: number; + source: SessionProfileSource; + scope: Capture; + path: Entry[]; + leaf: string | null; + failed: Set; + untrusted: Set; + ambiguous: boolean; +}; +type ForkTransfer = ForkSnapshot & { targetFile?: string; consumed: boolean }; +type ForkStore = { + version: 1; + preparations: WeakMap; + transfers: WeakMap; +}; +// Keep the key stable across code reloads; the value owns protocol negotiation. +const forkStoreKey = Symbol.for("gentle-pi.session-profile.fork-evidence"); +function forkStore(): ForkStore { + const existing = Reflect.get(globalThis, forkStoreKey) as + | ForkStore + | undefined; + if (existing) { + if ( + existing.version !== 1 || + !(existing.preparations instanceof WeakMap) || + !(existing.transfers instanceof WeakMap) + ) + throw new Error("fork-protocol"); + return existing; + } + const store: ForkStore = { + version: 1, + preparations: new WeakMap(), + transfers: new WeakMap(), + }; + Object.defineProperty(globalThis, forkStoreKey, { value: store }); + return store; +} function detached(value: T): T { try { return JSON.parse(JSON.stringify(value)); @@ -93,7 +166,8 @@ function noConversation(branch: Entry[]): boolean { * appendEntry is the synchronous public extension seam (void, not an ID). * Only an explicit operation can activate preflush memory. Refresh never * restores unwritten getBranch selections. Quarantine survives branch changes - * for each source/session/file. + * for each source/session/file. Only confirmed opaque fork evidence can convey + * copied-path quarantine; attachment handles never transfer activation. * Corroboration does not promise fsync, full ancestry or external-writer safety. */ export function createSessionProfileAppendController( @@ -101,13 +175,23 @@ export function createSessionProfileAppendController( initialAppendEntry: (customType: string, data: unknown) => void, options: SessionProfileAppendOptions = {}, ) { - const scopes = new Map>(); - const source = initialSource; - const appendEntry = initialAppendEntry; + // Evidence survives detach, but source identity still partitions all scopes. + const sourceScopes = new WeakMap< + SessionProfileSource, + Map> + >(); + let scopes = new Map>(); + sourceScopes.set(initialSource, scopes); + let source: SessionProfileSource | undefined = initialSource; + let appendEntry: ((customType: string, data: unknown) => void) | undefined = + initialAppendEntry; + let generation = 0; + const identity = {}; let active: Scope | undefined; - const readFile = + let readFile: ((file: string) => string) | undefined = options.readFile ?? ((file: string) => readFileSync(file, "utf8")); function capture(): Capture { + if (!source) throw new Error("detached-source"); const session = source.getSessionId(), file = source.getSessionFile(); if (!session || (file !== undefined && (typeof file !== "string" || !file))) @@ -152,6 +236,7 @@ export function createSessionProfileAppendController( ambiguous: false, uncertain: false, certified: new Map(), + untrusted: new Set(), }; files.set(c.file, state); } @@ -171,6 +256,7 @@ export function createSessionProfileAppendController( knownFailedEntryIds: s.failed, readFile(file) { try { + if (!readFile) throw new Error("detached-reader"); const text = readFile(file); s.established = true; return text; @@ -204,8 +290,9 @@ export function createSessionProfileAppendController( // certified selection when the newest entry's provenance is ambiguous. const latest = c.branch.findLast(isSessionProfileFamilyEntry); return ( - s.uncertain && - (!latest || !isDeepStrictEqual(s.certified.get(latest.id), latest)) + (latest !== undefined && s.untrusted.has(latest.id)) || + (s.uncertain && + (!latest || !isDeepStrictEqual(s.certified.get(latest.id), latest))) ); } function refresh(): SessionProfileAppendOutcome { @@ -259,6 +346,7 @@ export function createSessionProfileAppendController( } let threw = false; try { + if (!appendEntry) return unavailable("detached-source"); appendEntry(SESSION_PROFILE_CUSTOM_TYPE, detached(payload)); } catch { threw = true; @@ -373,9 +461,253 @@ export function createSessionProfileAppendController( return unavailable("append-not-corroborated"); } } - return { - bind: (name, models) => append(createSessionProfileBind(name, models)), - clear: () => append(createSessionProfileClear()), - refresh, - } satisfies SessionProfileAttachment; + function attachment(): SessionProfileAttachment { + const epoch = generation; + const live = () => source !== undefined && epoch === generation; + return { + bind: (name, models) => + live() + ? append(createSessionProfileBind(name, models)) + : unavailable("detached-source"), + clear: () => + live() + ? append(createSessionProfileClear()) + : unavailable("detached-source"), + refresh: () => (live() ? refresh() : unavailable("detached-source")), + }; + } + function negativeEvidence(path: Entry[], s?: Scope) { + const failed = new Set(), + untrusted = new Set(); + for (const entry of path) { + if (!isSessionProfileFamilyEntry(entry)) continue; + if (s?.failed.has(entry.id)) failed.add(entry.id); + if ( + s?.untrusted.has(entry.id) || + (s?.uncertain && !isDeepStrictEqual(s.certified.get(entry.id), entry)) + ) + untrusted.add(entry.id); + } + return { failed, untrusted, ambiguous: s?.ambiguous ?? false }; + } + function captureForkEvidence( + target: SessionProfileForkTarget, + view: SessionProfileForkView, + ): SessionProfileForkEvidence { + try { + if ( + source !== view || + !target.entryId || + !["at", "before"].includes(target.position) + ) + throw new Error("fork-source"); + const c = capture(); + const selected = detached(view.getEntry(target.entryId)) as Entry & { + message?: { role?: string }; + }; + if ( + !selected || + selected.id !== target.entryId || + (target.position === "before" && + (selected.type !== "message" || selected.message?.role !== "user")) + ) + throw new Error("fork-target"); + const leaf = + target.position === "at" + ? selected.id + : (selected.parentId as string | null); + if (leaf !== null && (typeof leaf !== "string" || !leaf)) + throw new Error("fork-target"); + const raw = leaf === null ? [] : view.getBranch(leaf); + if (!Array.isArray(raw)) throw new Error("fork-path"); + for (const value of raw) { + if (!value || typeof value !== "object") throw new Error("fork-metadata"); + const e = value as Entry; + if ( + isSessionProfileFamilyEntry(e) && + !hasSessionProfileCandidateMetadata(e) + ) + throw new Error("fork-metadata"); + } + const path = detached(raw) as Entry[]; + if ( + (leaf !== null && path.at(-1)?.id !== leaf) || + path.some((e) => typeof e.id !== "string" || !e.id) || + new Set(path.map((e) => e.id)).size !== path.length || + !stable(c) + ) + throw new Error("fork-path"); + const s = scopes.get(c.session)?.get(c.file); + const token = Object.freeze({}) as SessionProfileForkPreparation; + forkStore().preparations.set(token, { + owner: identity, + generation, + source, + scope: c, + path, + leaf, + ...negativeEvidence(path, s), + }); + return { status: "captured", evidence: token }; + } catch { + return { status: "indeterminate", reason: "unobservable-fork" }; + } + } + function detach( + context?: SessionProfileDetachContext, + ): SessionProfileForkHandoff | undefined { + let token: SessionProfileForkHandoff | undefined; + try { + if (context?.reason === "fork" && context.preparation) { + const prepared = forkStore().preparations.get(context.preparation); + const current = capture(); + if ( + !prepared || + prepared.owner !== identity || + prepared.generation !== generation || + prepared.source !== source || + !sameScope(prepared.scope, current) + ) + throw new Error("fork-preparation"); + const view = source as SessionProfileForkView; + const path = + prepared.leaf === null ? [] : detached(view.getBranch(prepared.leaf)); + if ( + !isDeepStrictEqual(prepared.path, path) || + !stable(current) || + (current.file !== undefined && !context.targetSessionFile) + ) + throw new Error("fork-transition"); + const latest = negativeEvidence( + prepared.path, + scopes.get(current.session)?.get(current.file), + ); + token = Object.freeze({}) as SessionProfileForkHandoff; + forkStore().transfers.set(token, { + ...prepared, + failed: new Set([...prepared.failed, ...latest.failed]), + untrusted: new Set([...prepared.untrusted, ...latest.untrusted]), + ambiguous: prepared.ambiguous || latest.ambiguous, + targetFile: context.targetSessionFile, + consumed: false, + }); + forkStore().preparations.delete(context.preparation); + } + } catch { + token = undefined; + } + // Even a failed proof capture must release the old runtime capability. + for (const files of scopes.values()) + for (const s of files.values()) s.pending = undefined; + generation++; + source = undefined; + appendEntry = undefined; + readFile = undefined; + active = undefined; + return token; + } + function applyFork( + context: SessionProfileAttachContext, + c: Capture, + s: Scope, + ) { + const claim = context.fork; + const evidence = claim && forkStore().transfers.get(claim.handoff); + if (!claim || !evidence || evidence.consumed) throw new Error("fork-handoff"); + evidence.consumed = true; + const view = claim.publicView; + const header = detached(view.getHeader()) as { + type?: string; + id?: string; + parentSession?: string; + }; + if ( + view !== source || + c.session === evidence.scope.session || + c.file !== evidence.targetFile || + context.previousSessionFile !== evidence.scope.file || + !header || + header.type !== "session" || + header.id !== c.session || + header.parentSession !== evidence.scope.file || + (c.file === undefined && view !== evidence.source) + ) + throw new Error("fork-destination"); + const expected = evidence.path.filter((e) => e.type !== "label"); + // The source cut may itself be a removed label. Its destination boundary is + // the last retained record, not an original label ID or a recreated label. + const copyLeaf = expected.at(-1)?.id ?? null; + // Validate the actual destination cut, not a matching ancestor subbranch. + // Recreated labels may follow the cut, but no later retained record may. + const copied = c.branch.filter((e) => e.type !== "label"); + // Orphan recovery can produce a suffix, not a fabricated complete ancestry. + const suffix = expected.slice(expected.length - copied.length); + if ( + copied.length > expected.length || + copied.length !== suffix.length || + (copied.at(-1)?.id ?? null) !== copyLeaf + ) + throw new Error("fork-path"); + const replacements = new Map(); + let labels: string[] = []; + for (const e of evidence.path) { + if (e.type === "label") { + labels.push(e.id); + continue; + } + for (const id of labels) replacements.set(id, e.id); + labels = []; + } + let parentId: string | null = null; + for (let index = 0; index < copied.length; index++) { + const from = suffix[index], + to = copied[index]; + const rewritten: Entry = { ...from, parentId }; + if (from.type === "compaction") + rewritten.firstKeptEntryId = + replacements.get(from.firstKeptEntryId as string) ?? from.firstKeptEntryId; + if (!isDeepStrictEqual(rewritten, to)) throw new Error("fork-record"); + parentId = to.id; + if (evidence.failed.has(to.id)) s.failed.add(to.id); + if (evidence.untrusted.has(to.id)) s.untrusted.add(to.id); + } + if (!stable(c)) throw new Error("fork-source-changed"); + if (copied.some(isSessionProfileFamilyEntry) && evidence.ambiguous) { + s.ambiguous = true; + s.uncertain = true; + } + } + // Optional callbacks belong to this attachment, not the retained owner. + // Omitted options use the default disk reader, never the factory's old reader. + function attach( + currentSource: SessionProfileSource, + currentAppend: (customType: string, data: unknown) => void, + context: SessionProfileAttachContext, + currentOptions: SessionProfileAppendOptions = {}, + ): SessionProfileAttachment { + detach(); + source = currentSource; + appendEntry = currentAppend; + readFile = + currentOptions.readFile ?? ((file: string) => readFileSync(file, "utf8")); + scopes = sourceScopes.get(currentSource) ?? new Map(); + sourceScopes.set(currentSource, scopes); + if (context.reason === "fork") { + try { + const c = capture(); + applyFork(context, c, scope(c)); + } catch { + // A fresh explicit disk-corroborated bind/clear can recover; no fallback. + try { + const s = scope(capture()); + s.ambiguous = true; + s.uncertain = true; + } catch { + /* Source remains unobservable. */ + } + } + } + return attachment(); + } + return { ...attachment(), captureForkEvidence, detach, attach }; } diff --git a/tests/session-profile-append-controller-fork.test.ts b/tests/session-profile-append-controller-fork.test.ts new file mode 100644 index 000000000..221f33840 --- /dev/null +++ b/tests/session-profile-append-controller-fork.test.ts @@ -0,0 +1,458 @@ +// Fork lifecycle seams against public Pi SessionManager fixtures in temp dirs. +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { + createSessionProfileAppendController, + type SessionProfileAppendOptions, + type SessionProfileAppendOutcome, + type SessionProfileForkTarget, +} from "../lib/session-profile-append-controller.ts"; + +type Controller = ReturnType; + +function fixture(memory = false) { + const root = mkdtempSync(join(tmpdir(), "profile-fork-")); + const manager = memory + ? SessionManager.inMemory(root) + : SessionManager.create(root, join(root, "sessions")); + let fail = false, + calls = 0; + const controller = createSessionProfileAppendController( + manager, + (type, data) => { + calls++; + manager.appendCustomEntry(type, data); + if (fail) throw new Error("injected callback failure after public append"); + }, + ); + return { + manager, + controller, + failing(value: boolean) { + fail = value; + }, + calls: () => calls, + }; +} +function flush(manager: SessionManager) { + manager.appendMessage({ + role: "user", + content: "isolated fork fixture", + timestamp: 1, + }); +} +function name(outcome: SessionProfileAppendOutcome, expected: string) { + const state = outcome.state as { status: string; binding?: { name: string } }; + assert.equal(state.status, "bound"); + assert.equal(state.binding?.name, expected); +} +function persistedCopy(manager: SessionManager, leaf: string) { + const sourceFile = manager.getSessionFile(); + assert.ok(sourceFile); + const copy = SessionManager.open(sourceFile, manager.getSessionDir()); + copy.createBranchedSession(leaf); + return copy; +} +// Mirrors the host order: capture, copy, detach with the preparation, then +// attach the destination with the handoff and its own public view. An +// in-memory fork rewrites the same manager, so it copies after detach. +function fork( + parent: Controller, + manager: SessionManager, + target: SessionProfileForkTarget, + copy: (prepared: boolean) => SessionManager, + options: SessionProfileAppendOptions = {}, +) { + const captured = parent.captureForkEvidence(target, manager); + const previousSessionFile = manager.getSessionFile(); + const inPlace = manager.getSessionFile() === undefined; + let child = inPlace ? manager : copy(captured.status === "captured"); + const handoff = parent.detach({ + reason: "fork", + targetSessionFile: inPlace ? undefined : child.getSessionFile(), + preparation: + captured.status === "captured" ? captured.evidence : undefined, + }); + if (inPlace) child = copy(captured.status === "captured"); + let appends = 0; + const append = (type: string, data: unknown) => { + appends++; + child.appendCustomEntry(type, data); + }; + // The host keeps one controller per manager; an in-memory fork reuses it. + const controller = + child === manager + ? parent + : createSessionProfileAppendController(child, append); + const attachment = controller.attach( + child, + append, + { + reason: "fork", + previousSessionFile, + ...(handoff ? { fork: { handoff, publicView: child } } : {}), + }, + options, + ); + return { + captured, + handoff, + child, + controller, + attachment, + outcome: attachment.refresh(), + appends: () => appends, + reattach: () => + createSessionProfileAppendController(child, append).attach(child, append, { + reason: "fork", + previousSessionFile, + ...(handoff ? { fork: { handoff, publicView: child } } : {}), + }), + }; +} + +test("disk-first fork retains copied failure quarantine but does not inherit binding", () => { + const f = fixture(); + f.failing(true); + assert.equal(f.controller.bind("failed", {}).status, "indeterminate"); + f.failing(false); + const failed = f.manager.getLeafId(); + assert.ok(failed); + assert.equal(existsSync(f.manager.getSessionFile()!), false); + // Genuine public first flush now writes the known-failed record as well. + flush(f.manager); + const forked = fork( + f.controller, + f.manager, + { entryId: failed, position: "at" }, + () => persistedCopy(f.manager, failed), + ); + assert.equal(forked.captured.status, "captured"); + assert.ok(forked.handoff); + // No conversation on the copied path: the child file need not exist yet. + assert.equal(forked.outcome.status, "indeterminate"); + assert.equal(existsSync(forked.child.getSessionFile()!), false); + flush(forked.child); + assert.equal(forked.attachment.refresh().state.status, "absent"); + assert.equal(forked.attachment.clear().status, "persisted"); + // A consumed handoff cannot be replayed into a second attachment. + assert.equal(forked.reattach().refresh().status, "indeterminate"); +}); + +test("actual copied cut beyond the prepared ancestor fails closed", () => { + const f = fixture(); + flush(f.manager); + assert.equal(f.controller.bind("trusted", {}).status, "persisted"); + const preparedCut = f.manager.getLeafId(); + assert.ok(preparedCut); + f.failing(true); + const failed = f.controller.bind("known-failed", {}); + f.failing(false); + assert.equal(failed.status, "append-failed-preserved"); + name(failed, "trusted"); + const actualCut = f.manager.getLeafId(); + assert.ok(actualCut); + assert.notEqual(actualCut, preparedCut); + const sourceFile = f.manager.getSessionFile()!; + const sourceEntries = JSON.stringify(f.manager.getEntries()); + const sourceDisk = readFileSync(sourceFile, "utf8"); + let childEntries = "", + childDisk = ""; + const forked = fork( + f.controller, + f.manager, + { entryId: preparedCut, position: "at" }, + () => { + const child = persistedCopy(f.manager, actualCut); + assert.ok(child.getBranch().some((entry) => entry.id === preparedCut)); + childEntries = JSON.stringify(child.getEntries()); + childDisk = readFileSync(child.getSessionFile()!, "utf8"); + assert.ok(childDisk.includes(actualCut), "public copy stores the failure"); + return child; + }, + ); + assert.deepEqual(forked.outcome, { + status: "indeterminate", + state: { status: "indeterminate", reason: "ambiguous-append" }, + }); + assert.deepEqual(forked.attachment.refresh(), forked.outcome); + assert.equal(forked.appends(), 0); + assert.equal(f.calls(), 2); + assert.equal(JSON.stringify(forked.child.getEntries()), childEntries); + assert.equal(readFileSync(forked.child.getSessionFile()!, "utf8"), childDisk); + assert.equal(JSON.stringify(f.manager.getEntries()), sourceEntries); + assert.equal(readFileSync(sourceFile, "utf8"), sourceDisk); +}); + +for (const position of ["before", "at"] as const) { + test(`cancelled ${position} capture leaves entries, activation and callbacks unchanged`, () => { + const f = fixture(); + assert.equal(f.controller.bind("pending", {}).status, "not-persisted"); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const before = JSON.stringify(f.manager.getEntries()); + const captured = f.controller.captureForkEvidence( + { entryId: position === "at" ? leaf : "missing", position }, + f.manager, + ); + assert.equal( + captured.status, + position === "at" ? "captured" : "indeterminate", + ); + assert.equal(JSON.stringify(f.manager.getEntries()), before); + assert.equal(f.controller.refresh().status, "not-persisted"); + assert.equal(f.controller.bind("next", {}).status, "not-persisted"); + assert.equal(f.calls(), 2); + }); +} + +test("valid cancelled before-user capture leaves disk and explicit binding unchanged", () => { + const f = fixture(); + flush(f.manager); + const user = f.manager.getLeafId(); + assert.ok(user); + f.controller.bind("current", {}); + const entries = JSON.stringify(f.manager.getEntries()); + const disk = readFileSync(f.manager.getSessionFile()!, "utf8"); + assert.equal( + f.controller.captureForkEvidence( + { entryId: user, position: "before" }, + f.manager, + ).status, + "captured", + ); + assert.equal(JSON.stringify(f.manager.getEntries()), entries); + assert.equal(readFileSync(f.manager.getSessionFile()!, "utf8"), disk); + name(f.controller.refresh(), "current"); + assert.equal(f.calls(), 1); +}); + +test("capture rejects a public view other than the attached source", () => { + const f = fixture(); + flush(f.manager); + const other = fixture(); + assert.deepEqual( + f.controller.captureForkEvidence( + { entryId: f.manager.getLeafId()!, position: "at" }, + other.manager, + ), + { status: "indeterminate", reason: "unobservable-fork" }, + ); +}); + +test("label removal permits only the expected copied parentId rechain", () => { + const f = fixture(); + f.controller.bind("base", {}); + flush(f.manager); + const base = f.manager.getLeafId(); + assert.ok(base); + f.manager.appendLabelChange(base, "fixture-label"); + f.controller.bind("selected", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const original = f.manager.getEntry(leaf)!; + const forked = fork( + f.controller, + f.manager, + { entryId: leaf, position: "at" }, + () => persistedCopy(f.manager, leaf), + ); + assert.notEqual(forked.child.getEntry(leaf)?.parentId, original.parentId); + name(forked.outcome, "selected"); +}); + +for (const position of ["before", "at"] as const) { + test(`fork boundary label supports ${position} without requiring its removed ID`, () => { + const f = fixture(); + flush(f.manager); + f.controller.bind("retained", {}); + const retained = f.manager.getLeafId(); + assert.ok(retained); + f.manager.appendLabelChange(retained, "boundary"); + const label = f.manager.getLeafId(); + assert.ok(label); + let entryId = label; + if (position === "before") { + flush(f.manager); + entryId = f.manager.getLeafId()!; + } + const forked = fork(f.controller, f.manager, { entryId, position }, () => { + const copy = persistedCopy(f.manager, label); + assert.equal(copy.getEntry(label), undefined); + return copy; + }); + assert.equal(forked.captured.status, "captured"); + name(forked.outcome, "retained"); + }); +} + +test("actual fork path excludes failed sibling and never imports its quarantine", () => { + const f = fixture(); + f.controller.bind("base", {}); + flush(f.manager); + const base = f.manager.getLeafId(); + assert.ok(base); + f.failing(true); + f.controller.bind("failed-left", {}); + f.failing(false); + const failed = f.manager.getLeafId(); + f.manager.branch(base); + f.controller.bind("right", {}); + const right = f.manager.getLeafId(); + assert.ok(right); + assert.notEqual(right, failed); + const forked = fork( + f.controller, + f.manager, + { entryId: right, position: "at" }, + () => persistedCopy(f.manager, right), + ); + assert.equal( + forked.child.getBranch().some((entry) => entry.id === failed), + false, + ); + name(forked.outcome, "right"); +}); + +for (const changed of ["data", "timestamp", "parentId", "extra"] as const) { + test(`copied same-ID ${changed} mutation fails closed without blessing destination disk`, () => { + const f = fixture(); + flush(f.manager); + f.controller.bind("trusted", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + let copy: SessionManager | undefined; + const forked = fork( + f.controller, + f.manager, + { entryId: leaf, position: "at" }, + () => { + copy = persistedCopy(f.manager, leaf); + const row = copy.getEntry(leaf)! as unknown as Record; + if (changed === "data") row.data.name = "FAILED"; + if (changed === "timestamp") row.timestamp = "changed"; + if (changed === "parentId") row.parentId = "unexpected-parent"; + if (changed === "extra") row.extra = { changed: true }; + return copy; + }, + { + // Inject matching disk content, not ordinary Pi ID reuse. + readFile: (path) => + copy && path === copy.getSessionFile() + ? [copy.getHeader(), ...copy.getEntries()] + .map((entry) => JSON.stringify(entry)) + .join("\n") + : readFileSync(path, "utf8"), + }, + ); + assert.equal(forked.outcome.status, "indeterminate"); + assert.equal(forked.attachment.clear().status, "persisted"); + forked.child.branch(leaf); + assert.equal(forked.attachment.refresh().status, "indeterminate"); + }); +} + +test("in-memory fork keeps same-manager evidence but never restores pending state", () => { + const f = fixture(true); + assert.equal(f.controller.bind("pending", {}).status, "not-persisted"); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const forked = fork( + f.controller, + f.manager, + { entryId: leaf, position: "at" }, + () => { + f.manager.createBranchedSession(leaf); + return f.manager; + }, + ); + assert.equal(forked.captured.status, "captured"); + assert.equal(forked.outcome.status, "indeterminate"); + assert.equal( + forked.attachment.bind("new-explicit", {}).status, + "not-persisted", + ); + // The parent attachment was revoked by detach and cannot append. + assert.equal(f.controller.clear().status, "indeterminate"); +}); + +test("fork before root creates empty child without parent binding", () => { + const f = fixture(); + flush(f.manager); + const firstUser = f.manager.getLeafId(); + assert.ok(firstUser); + f.controller.bind("parent", {}); + const previousSessionFile = f.manager.getSessionFile(); + const forked = fork( + f.controller, + f.manager, + { entryId: firstUser, position: "before" }, + () => { + const child = SessionManager.create( + f.manager.getCwd(), + f.manager.getSessionDir(), + ); + child.newSession({ parentSession: previousSessionFile }); + return child; + }, + ); + assert.equal(forked.captured.status, "captured"); + assert.equal(forked.outcome.status, "indeterminate"); + assert.equal(forked.attachment.bind("child", {}).status, "not-persisted"); +}); + +test("detach rejects a preparation captured by another controller", () => { + const f = fixture(); + flush(f.manager); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const other = createSessionProfileAppendController(f.manager, () => {}); + const captured = other.captureForkEvidence( + { entryId: leaf, position: "at" }, + f.manager, + ); + assert.equal(captured.status, "captured"); + assert.equal( + f.controller.detach({ + reason: "fork", + targetSessionFile: join(f.manager.getSessionDir(), "child.jsonl"), + preparation: captured.status === "captured" ? captured.evidence : undefined, + }), + undefined, + ); + // Even a rejected proof still revokes the old capability. + assert.equal(f.controller.refresh().status, "indeterminate"); +}); + +test("empty destination cannot claim a prepared non-empty copied path", () => { + const f = fixture(); + flush(f.manager); + f.controller.bind("parent", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const previousSessionFile = f.manager.getSessionFile(); + const forked = fork( + f.controller, + f.manager, + { entryId: leaf, position: "at" }, + () => { + const child = SessionManager.create( + f.manager.getCwd(), + f.manager.getSessionDir(), + ); + child.newSession({ parentSession: previousSessionFile }); + return child; + }, + ); + assert.equal(forked.captured.status, "captured"); + assert.equal(forked.outcome.status, "indeterminate"); + // A rejected destination cut cannot activate even a fresh explicit setup. + assert.deepEqual(forked.attachment.bind("child", {}), { + status: "indeterminate", + state: { status: "indeterminate", reason: "append-not-corroborated" }, + }); +}); diff --git a/tests/session-profile-append-controller-pi.test.ts b/tests/session-profile-append-controller-pi.test.ts index 74f2ecb09..e3dd9dd32 100644 --- a/tests/session-profile-append-controller-pi.test.ts +++ b/tests/session-profile-append-controller-pi.test.ts @@ -245,3 +245,46 @@ test("public sibling disk records never override the selected active branch", () const other = fixture(); assert.equal(other.adapter.refresh().status, "indeterminate"); }); + +for (const failedOperation of ["bind", "clear"] as const) { + test(`public EISDIR ${failedOperation} quarantine survives detach and fresh append capability`, () => { + const f = fixture(); + f.adapter.bind("trusted", {}); + flush(f.manager); + const file = f.manager.getSessionFile(); + assert.ok(file); + const original = readFileSync(file, "utf8"); + renameSync(file, `${file}.saved`); + mkdirSync(file); + const failed = + failedOperation === "bind" + ? f.adapter.bind("FAILED", {}) + : f.adapter.clear(); + assert.equal(failed.status, "indeterminate"); + assert.equal(readFileSync(`${file}.saved`, "utf8"), original); + const ghost = f.manager.getLeafId(); + assert.ok(ghost); + renameSync(file, `${file}.fault-directory`); + renameSync(`${file}.saved`, file); + f.adapter.detach(); + let calls = 0; + const current = f.adapter.attach( + f.manager, + (t, d) => { + calls++; + f.manager.appendCustomEntry(t, d); + }, + { reason: "reload" }, + ); + bound(current.refresh(), "trusted"); + const length = f.manager.getBranch().length; + assert.equal(f.adapter.bind("stale", {}).status, "indeterminate"); + assert.equal(f.manager.getBranch().length, length); + assert.equal(calls, 0); + assert.equal(current.clear().status, "persisted"); + assert.equal(calls, 1); + f.manager.branch(ghost); + bound(current.refresh(), "trusted"); + assert.equal(f.errors.length, 1); + }); +} diff --git a/tests/session-profile-append-controller.test.ts b/tests/session-profile-append-controller.test.ts index 233f2247b..ffe7ac960 100644 --- a/tests/session-profile-append-controller.test.ts +++ b/tests/session-profile-append-controller.test.ts @@ -613,3 +613,119 @@ test("a throwing post-append stability recheck is never adopted later", () => { armed = false; bound(controller.bind("again", {}), "again"); }); + +for (const operation of ["bind", "clear"] as const) { + test(`detach revokes pending ${operation} and initial lease without redirecting callbacks`, () => { + const f = fixture(); + const owner = f.controller as typeof f.controller & { + detach(): void; + attach( + source: typeof f.source, + append: typeof f.append, + context: { reason: string }, + ): typeof f.controller; + }; + const selected = + operation === "bind" ? owner.bind("pending", {}) : owner.clear(); + assert.equal(selected.status, "not-persisted"); + assert.equal(typeof owner.detach, "function"); + owner.detach(); + owner.detach(); + const length = f.rows.length; + assert.equal(owner.refresh().status, "indeterminate"); + assert.equal(owner.bind("stale", {}).status, "indeterminate"); + assert.equal(owner.clear().status, "indeterminate"); + assert.equal(f.rows.length, length); + let calls = 0; + const current = owner.attach( + f.source, + (t, d) => { + calls++; + f.append(t, d); + }, + { reason: "reload" }, + { readFile: f.readFile }, + ); + assert.equal(current.refresh().status, "indeterminate"); + assert.equal(current.bind("current", {}).status, "not-persisted"); + assert.equal(owner.bind("old-generation", {}).status, "indeterminate"); + assert.equal(calls, 1); + assert.equal(f.rows.length, length + 1); + }); +} +test("detach retains observed established history and failed-ID exclusion after recovery", () => { + const f = fixture(); + f.mode = "disk"; + const owner = f.controller as typeof f.controller & { + detach(): void; + attach( + source: typeof f.source, + append: typeof f.append, + context: { reason: string }, + ): typeof f.controller; + }; + owner.bind("trusted", {}); + f.mode = "throw"; + owner.bind("failed", {}); + f.save(); + assert.equal(typeof owner.detach, "function"); + owner.detach(); + const current = owner.attach( + f.source, + f.append, + { reason: "reload" }, + { readFile: f.readFile }, + ); + bound(current.refresh(), "trusted"); + f.text = undefined; + f.rows = []; + assert.equal(current.refresh().status, "indeterminate"); + f.mode = "normal"; + assert.equal( + current.bind("unwritten-after-established-loss", {}).status, + "indeterminate", + ); +}); +for (const changed of ["data", "timestamp", "parentId", "extra"] as const) { + test(`same-ID ${changed} ambiguity survives detach, exact recovery and branch return`, () => { + const f = fixture(); + f.mode = "disk"; + let fail = false; + const append = (t: string, d: unknown) => { + if (!fail) return f.append(t, d); + const row = f.rows[0]; + if (changed === "data") row.data.name = "FAILED"; + if (changed === "timestamp") row.timestamp = "changed"; + if (changed === "parentId") row.parentId = "unexpected-parent"; + if (changed === "extra") row.extra = true; + f.save(); + throw new Error("injected whole-record mutation"); + }; + const owner = createSessionProfileAppendController(f.source, append, { + readFile: () => f.text!, + }) as ReturnType & { + detach(): void; + attach( + source: typeof f.source, + appendEntry: (type: string, data: unknown) => void, + context: { reason: string }, + ): typeof f.controller; + }; + owner.bind("trusted", {}); + fail = true; + assert.equal(owner.bind("failed", {}).status, "indeterminate"); + const historical = f.rows.slice(); + assert.equal(typeof owner.detach, "function"); + owner.detach(); + fail = false; + const current = owner.attach( + f.source, + append, + { reason: "reload" }, + { readFile: f.readFile }, + ); + assert.equal(current.clear().status, "persisted"); + f.rows = historical; + assert.equal(current.refresh().status, "indeterminate"); + }); +} From 6201ce0ab48d9bd29cb3dcaed4cd67abe620a655 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 10:23:21 -0600 Subject: [PATCH 2/3] feat(profiles): add session profile lifecycle integration adapter Refs #1064 --- lib/session-profile-append-controller.ts | 3 +- lib/session-profile-integration.ts | 285 +++++++++ tests/session-profile-integration.test.ts | 693 ++++++++++++++++++++++ 3 files changed, 980 insertions(+), 1 deletion(-) create mode 100644 lib/session-profile-integration.ts create mode 100644 tests/session-profile-integration.test.ts diff --git a/lib/session-profile-append-controller.ts b/lib/session-profile-append-controller.ts index 679f66906..9d873c7ea 100644 --- a/lib/session-profile-append-controller.ts +++ b/lib/session-profile-append-controller.ts @@ -49,7 +49,8 @@ export type SessionProfileControllerReason = | "detached-source" | "source-changed" | "unobservable-append" - | "unobservable-source"; + | "unobservable-source" + | "unavailable-lifecycle"; export type SessionProfileAppendOutcome = { status: | "persisted" diff --git a/lib/session-profile-integration.ts b/lib/session-profile-integration.ts new file mode 100644 index 000000000..9c5e7e25c --- /dev/null +++ b/lib/session-profile-integration.ts @@ -0,0 +1,285 @@ +// Lifecycle adapter only: no extension registration, binding publication or UI. +import { + createSessionProfileAppendController, + type SessionProfileAppendOptions, + type SessionProfileAppendOutcome, + type SessionProfileAttachment, + type SessionProfileForkView, + type SessionProfileForkTarget, + type SessionProfileForkPreparation, + type SessionProfileForkHandoff, +} from "./session-profile-append-controller.ts"; + +export type SessionProfileLifecycleManager = SessionProfileForkView; +export type SessionProfileLifecycleReason = + | "startup" + | "reload" + | "new" + | "resume" + | "fork"; +type Owner = { + controller: ReturnType; + adapter: object; + attachment?: SessionProfileAttachment; + preparation?: SessionProfileForkPreparation; + outgoing?: Transfer; + pending?: Promise; +}; +type Transfer = { + manager: SessionProfileLifecycleManager; + previousFile?: string; + targetFile?: string; + handoff?: SessionProfileForkHandoff; +}; +type Registry = { + version: 1; + owners: WeakMap; + transfers: Set; +}; +// Stable key, explicit protocol: a new implementation must not reset evidence. +const registryKey = Symbol.for("gentle-pi.session-profile.lifecycle"); +function registry(): Registry { + const existing = Reflect.get(globalThis, registryKey) as Registry | undefined; + if (existing) { + if ( + existing.version !== 1 || + !(existing.owners instanceof WeakMap) || + !(existing.transfers instanceof Set) + ) + throw new Error("lifecycle-protocol"); + return existing; + } + const value: Registry = { + version: 1, + owners: new WeakMap(), + transfers: new Set(), + }; + Object.defineProperty(globalThis, registryKey, { value }); + return value; +} +function unavailable(): SessionProfileAppendOutcome { + return { + status: "indeterminate", + state: { status: "indeterminate", reason: "unavailable-lifecycle" }, + }; +} +function inactive(): SessionProfileAttachment { + return { bind: unavailable, clear: unavailable, refresh: unavailable }; +} +/** Read/revalidate the existing owner without adopting its adapter identity. */ +export function readCurrentSessionProfileOutcome( + manager: SessionProfileLifecycleManager, +): SessionProfileAppendOutcome | undefined { + try { + const owner = registry().owners.get(manager); + return owner ? (owner.attachment?.refresh() ?? unavailable()) : undefined; + } catch { + return unavailable(); + } +} + +/** Serialize explicit selections; lifecycle hooks drain this same owner queue. + * The promise is transient work, not another quarantine or authority store. + */ +export async function runCurrentSessionProfileSelection( + manager: SessionProfileLifecycleManager, + effect: ( + attachment: SessionProfileAttachment, + current: () => boolean, + ) => Promise, +): Promise { + const owner = registry().owners.get(manager); + if (!owner?.attachment) return undefined; + const attachment = owner.attachment, + adapter = owner.adapter; + const id = manager.getSessionId(), + file = manager.getSessionFile(); + const current = () => + registry().owners.get(manager) === owner && + owner.adapter === adapter && + owner.attachment === attachment && + manager.getSessionId() === id && + manager.getSessionFile() === file; + const operation = (owner.pending ?? Promise.resolve()).then(() => + current() ? effect(attachment, current) : undefined, + ); + const pending = operation.then( + () => {}, + () => {}, + ); + owner.pending = pending; + try { + return await operation; + } finally { + if (owner.pending === pending) owner.pending = undefined; + } +} + +/** Await before fork-copy/tree movement and before detach/invalidation. */ +export async function waitCurrentSessionProfileSelection( + manager: SessionProfileLifecycleManager, +): Promise { + let pending: Promise | undefined; + while ((pending = registry().owners.get(manager)?.pending)) await pending; +} + +/** The registry retains controller evidence, never old ctx/pi or append handles. + * Manager identity covers same-process reload only. Replacements require an + * exact, uniquely matched confirmed handoff; paths alone never restore binding. + */ +export function createSessionProfileIntegration( + options: SessionProfileAppendOptions = {}, +) { + const identity = {}; + function owned(manager: SessionProfileLifecycleManager): Owner | undefined { + const owner = registry().owners.get(manager); + return owner?.adapter === identity ? owner : undefined; + } + function forget(owner: Owner) { + if (owner.outgoing) registry().transfers.delete(owner.outgoing); + owner.outgoing = undefined; + } + return { + isCurrent(manager: SessionProfileLifecycleManager): boolean { + return owned(manager)?.attachment !== undefined; + }, + start( + manager: SessionProfileLifecycleManager, + append: (type: string, data: unknown) => void, + event: { + reason: SessionProfileLifecycleReason; + previousSessionFile?: string; + }, + ) { + let owner: Owner | undefined; + try { + const store = registry(); + owner = store.owners.get(manager); + if (!owner) { + owner = { + controller: createSessionProfileAppendController( + manager, + append, + options, + ), + adapter: identity, + }; + store.owners.set(manager, owner); + } + owner.controller.detach(); + owner.adapter = identity; + owner.preparation = undefined; + const location = { + sessionId: manager.getSessionId(), + sessionFile: manager.getSessionFile(), + }; + let handoff: SessionProfileForkHandoff | undefined; + if (event.reason === "fork") { + const candidates = [...store.transfers].filter( + (t) => + t.previousFile === event.previousSessionFile && + t.targetFile === location.sessionFile && + (location.sessionFile !== undefined || t.manager === manager), + ); + // Consume even a conflicting match so later starts cannot retry it. + for (const candidate of candidates) store.transfers.delete(candidate); + if (candidates.length === 1) handoff = candidates[0].handoff; + } + forget(owner); + owner.attachment = owner.controller.attach( + manager, + append, + { + reason: event.reason, + previousSessionFile: event.previousSessionFile, + ...(handoff ? { fork: { handoff, publicView: manager } } : {}), + }, + options, + ); + const outcome = owner.attachment.refresh(); + const stable = + manager.getSessionId() === location.sessionId && + manager.getSessionFile() === location.sessionFile; + return { + attachment: owner.attachment, + outcome: stable ? outcome : unavailable(), + scope: location, + }; + } catch { + owner?.controller.detach(); + if (owner) owner.attachment = undefined; + return { attachment: inactive(), outcome: unavailable(), scope: undefined }; + } + }, + beforeFork( + manager: SessionProfileLifecycleManager, + target: SessionProfileForkTarget, + ) { + try { + const owner = owned(manager); + if (!owner?.attachment) + return { + status: "indeterminate" as const, + reason: "unavailable-lifecycle", + }; + forget(owner); + const result = owner.controller.captureForkEvidence(target, manager); + owner.preparation = + result.status === "captured" ? result.evidence : undefined; + return result; + } catch { + return { + status: "indeterminate" as const, + reason: "unavailable-lifecycle", + }; + } + }, + shutdown( + manager: SessionProfileLifecycleManager, + event: { + reason: SessionProfileLifecycleReason | "quit"; + targetSessionFile?: string; + }, + ) { + try { + const owner = owned(manager); + if (!owner) return; + forget(owner); + const previousFile = manager.getSessionFile(); + const handoff = owner.controller.detach({ + reason: event.reason === "startup" ? "quit" : event.reason, + targetSessionFile: event.targetSessionFile, + preparation: owner.preparation, + }); + owner.attachment = undefined; + owner.preparation = undefined; + if (event.reason === "fork") { + const transfer = { + manager, + previousFile, + targetFile: event.targetSessionFile, + handoff, + }; + registry().transfers.add(transfer); + owner.outgoing = transfer; + } + } catch { + // Cleanup still revokes capabilities when observing the transition fails. + try { + const owner = owned(manager); + owner?.controller.detach(); + if (owner) owner.attachment = undefined; + } catch { + /* Protocol itself is unavailable. */ + } + } + }, + tree(manager: SessionProfileLifecycleManager): SessionProfileAppendOutcome { + try { + return owned(manager)?.attachment?.refresh() ?? unavailable(); + } catch { + return unavailable(); + } + }, + }; +} diff --git a/tests/session-profile-integration.test.ts b/tests/session-profile-integration.test.ts new file mode 100644 index 000000000..1f7195643 --- /dev/null +++ b/tests/session-profile-integration.test.ts @@ -0,0 +1,693 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { + AgentSessionRuntime, + SessionManager, +} from "@earendil-works/pi-coding-agent"; +import { createSessionProfileIntegration } from "../lib/session-profile-integration.ts"; + +const version = JSON.parse( + readFileSync( + join( + dirname( + fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")), + ), + "..", + "package.json", + ), + "utf8", + ), +).version; +assert.ok( + Number(version.split(".")[0]) >= 1, + "Lifecycle public fixtures require Pi >=1.0.0", +); +console.info(`Public lifecycle fixture API: ${version}`); +function fixture(memory = false) { + const root = mkdtempSync(join(tmpdir(), "profile-lifecycle-")); + const manager = memory + ? SessionManager.inMemory(root) + : SessionManager.create(root, join(root, "sessions")); + return { + manager, + integration: createSessionProfileIntegration(), + append: (type: string, data: unknown): void => { + manager.appendCustomEntry(type, data); + }, + }; +} +function flush(manager: SessionManager) { + manager.appendMessage({ + role: "user", + content: "isolated lifecycle fixture", + timestamp: 1, + }); +} +function name(outcome: { state: unknown }, expected: string) { + const state = outcome.state as { status: string; binding?: { name: string } }; + assert.equal(state.status, "bound"); + assert.equal(state.binding?.name, expected); +} +function persistedCopy(manager: SessionManager, leaf: string) { + const sourceFile = manager.getSessionFile(); + assert.ok(sourceFile); + const copy = SessionManager.open(sourceFile, manager.getSessionDir()); + copy.createBranchedSession(leaf); + return copy; +} +for (const operation of ["bind", "clear"] as const) { + test(`reload revokes unwritten ${operation} and old capability; later nonfailed disk restoration remains valid`, () => { + const f = fixture(); + let oldCalls = 0, + newCalls = 0; + const initial = f.integration.start( + f.manager, + (t, d) => { + oldCalls++; + f.append(t, d); + }, + { reason: "startup" }, + ); + const selected = + operation === "bind" + ? initial.attachment.bind("pending", {}) + : initial.attachment.clear(); + assert.equal(selected.status, "not-persisted"); + f.integration.shutdown(f.manager, { reason: "reload" }); + assert.equal(initial.attachment.refresh().status, "indeterminate"); + assert.equal(initial.attachment.bind("stale", {}).status, "indeterminate"); + assert.equal(oldCalls, 1); + const reloadedModule = createSessionProfileIntegration(); + const current = reloadedModule.start( + f.manager, + (t, d) => { + newCalls++; + f.append(t, d); + }, + { reason: "reload" }, + ); + assert.equal(current.outcome.status, "indeterminate"); + assert.equal(newCalls, 0); + flush(f.manager); + const restored = reloadedModule.tree(f.manager); + assert.equal(restored.status, "persisted"); + assert.equal( + restored.state.status, + operation === "bind" ? "bound" : "cleared", + ); + if (operation === "bind") name(restored, "pending"); + assert.equal(current.attachment.bind("current", {}).status, "persisted"); + assert.equal(newCalls, 1); + assert.equal(oldCalls, 1); + // A handler owned by the invalidated adapter cannot detach the new epoch. + f.integration.shutdown(f.manager, { reason: "reload" }); + name(current.attachment.refresh(), "current"); + }); +} +for (const reader of ["replacement", "default"] as const) { + test(`reload replaces optional readFile with ${reader} and never calls the retired reader`, () => { + const f = fixture(); + flush(f.manager); + let oldReads = 0, + currentReads = 0, + oldAppends = 0, + currentAppends = 0; + const previous = createSessionProfileIntegration({ + readFile: (file) => { + oldReads++; + return readFileSync(file, "utf8"); + }, + }); + const initial = previous.start( + f.manager, + (type, data) => { + oldAppends++; + f.append(type, data); + }, + { reason: "startup" }, + ); + assert.equal(initial.attachment.bind("trusted", {}).status, "persisted"); + assert.ok(oldReads > 0, "initial factory options still read disk"); + previous.shutdown(f.manager, { reason: "reload" }); + const frozenReads = oldReads; + const frozenEntries = f.manager.getEntries().length; + assert.equal(initial.attachment.refresh().status, "indeterminate"); + assert.equal(initial.attachment.clear().status, "indeterminate"); + assert.equal(f.manager.getEntries().length, frozenEntries); + assert.equal(oldReads, frozenReads); + const current = createSessionProfileIntegration( + reader === "replacement" + ? { + readFile: (file) => { + currentReads++; + return readFileSync(file, "utf8"); + }, + } + : {}, + ); + const started = current.start( + f.manager, + (type, data) => { + currentAppends++; + f.append(type, data); + }, + { reason: "reload" }, + ); + assert.equal(started.outcome.status, "persisted"); + name(started.outcome, "trusted"); + assert.equal( + oldReads, + frozenReads, + "reload must not invoke the retired readFile", + ); + assert.equal(currentReads > 0, reader === "replacement"); + assert.equal(currentAppends, 0); + assert.equal(started.attachment.bind("current", {}).status, "persisted"); + assert.equal(started.attachment.clear().status, "persisted"); + assert.equal(f.manager.getEntries().length, frozenEntries + 2); + assert.equal(currentAppends, 2); + assert.equal(oldAppends, 1); + const frozenCurrentReads = currentReads; + previous.shutdown(f.manager, { reason: "reload" }); + assert.equal(previous.tree(f.manager).status, "indeterminate"); + assert.equal(initial.attachment.bind("stale", {}).status, "indeterminate"); + assert.equal(currentReads, frozenCurrentReads); + assert.equal(f.manager.getEntries().length, frozenEntries + 2); + assert.equal(started.attachment.refresh().state.status, "cleared"); + assert.equal(oldReads, frozenReads); + }); +} +function failedThenFlushed() { + const f = fixture(); + const initial = f.integration.start( + f.manager, + (t, d) => { + f.append(t, d); + throw new Error("injected callback failure after public custom append"); + }, + { reason: "startup" }, + ); + assert.equal(initial.attachment.bind("failed", {}).status, "indeterminate"); + const failed = f.manager.getLeafId(); + assert.ok(failed); + assert.equal(existsSync(f.manager.getSessionFile()!), false); + // Genuine public first flush now writes the known-failed record as well. + flush(f.manager); + return { ...f, initial, failed }; +} +test("reload retains failed quarantine after genuine first flush", () => { + const f = failedThenFlushed(); + f.integration.shutdown(f.manager, { reason: "reload" }); + const current = createSessionProfileIntegration().start(f.manager, f.append, { + reason: "reload", + }); + assert.equal(current.outcome.state.status, "absent"); + assert.equal(current.attachment.clear().status, "persisted"); + f.manager.branch(f.failed); + assert.equal(current.attachment.refresh().state.status, "absent"); +}); +test("confirmed persisted disk-first fork retains copied failure but does not inherit binding", () => { + const f = failedThenFlushed(); + const previousSessionFile = f.manager.getSessionFile(); + assert.equal( + f.integration.beforeFork(f.manager, { entryId: f.failed, position: "at" }) + .status, + "captured", + ); + const copy = persistedCopy(f.manager, f.failed); + assert.notEqual(copy, f.manager); + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: copy.getSessionFile(), + }); + const current = createSessionProfileIntegration().start( + copy, + (t, d) => { + copy.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile }, + ); + // This copied path has no conversation: even a saved parent does not prove + // that the new child file exists. Quarantine must survive its later flush. + assert.equal(current.outcome.status, "indeterminate"); + assert.equal(existsSync(copy.getSessionFile()!), false); + flush(copy); + assert.equal(current.attachment.refresh().state.status, "absent"); + assert.equal(current.attachment.clear().status, "persisted"); + const replay = createSessionProfileIntegration().start( + copy, + (t, d) => { + copy.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile }, + ); + assert.equal(replay.outcome.status, "indeterminate"); +}); +test("S5 rejects actual copied cut beyond prepared ancestor", () => { + const f = fixture(); + flush(f.manager); + let fail = false, + sourceAppends = 0, + childAppends = 0; + const live = f.integration.start( + f.manager, + (type, data) => { + sourceAppends++; + f.append(type, data); + if (fail) + throw new Error("injected callback failure after public disk append"); + }, + { reason: "startup" }, + ); + assert.equal(live.attachment.bind("trusted", {}).status, "persisted"); + const preparedCut = f.manager.getLeafId(); + assert.ok(preparedCut); + fail = true; + const failed = live.attachment.bind("known-failed", {}); + assert.equal(failed.status, "append-failed-preserved"); + name(failed, "trusted"); + name(live.attachment.refresh(), "trusted"); + const actualCut = f.manager.getLeafId(); + assert.ok(actualCut); + assert.notEqual(actualCut, preparedCut); + const sourceFile = f.manager.getSessionFile(); + assert.ok(sourceFile); + const sourceEntries = JSON.stringify(f.manager.getEntries()); + const sourceDisk = readFileSync(sourceFile, "utf8"); + assert.equal( + f.integration.beforeFork(f.manager, { entryId: preparedCut, position: "at" }) + .status, + "captured", + ); + const child = persistedCopy(f.manager, actualCut); + assert.equal(child.getLeafId(), actualCut); + assert.ok(child.getBranch().some((entry) => entry.id === preparedCut)); + assert.ok(child.getBranch().some((entry) => entry.id === actualCut)); + const childFile = child.getSessionFile(); + assert.ok(childFile); + const childEntries = JSON.stringify(child.getEntries()); + const childDisk = readFileSync(childFile, "utf8"); + assert.ok( + childDisk.includes(actualCut), + "actual public copy stores the failed record", + ); + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: childFile, + }); + assert.equal(live.attachment.clear().status, "indeterminate"); + const started = createSessionProfileIntegration().start( + child, + (type, data) => { + childAppends++; + child.appendCustomEntry(type, data); + }, + { reason: "fork", previousSessionFile: sourceFile }, + ); + assert.deepEqual(started.outcome, { + status: "indeterminate", + state: { status: "indeterminate", reason: "ambiguous-append" }, + }); + assert.deepEqual(started.attachment.refresh(), started.outcome); + assert.equal(childAppends, 0); + assert.equal(sourceAppends, 2); + assert.equal(JSON.stringify(child.getEntries()), childEntries); + assert.equal(readFileSync(childFile, "utf8"), childDisk); + assert.equal(JSON.stringify(f.manager.getEntries()), sourceEntries); + assert.equal(readFileSync(sourceFile, "utf8"), sourceDisk); +}); +for (const position of ["before", "at"] as const) { + test(`cancelled ${position} fork leaves explicit activation and callbacks unchanged`, () => { + const f = fixture(); + let calls = 0; + const current = f.integration.start( + f.manager, + (t, d) => { + calls++; + f.append(t, d); + }, + { reason: "startup" }, + ); + assert.equal(current.attachment.bind("pending", {}).status, "not-persisted"); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const target = position === "at" ? leaf : "missing"; + const before = JSON.stringify(f.manager.getEntries()); + const proposed = f.integration.beforeFork(f.manager, { + entryId: target, + position, + }); + assert.equal( + proposed.status, + position === "at" ? "captured" : "indeterminate", + ); + assert.equal(JSON.stringify(f.manager.getEntries()), before); + assert.equal(current.attachment.refresh().status, "not-persisted"); + assert.equal(current.attachment.bind("next", {}).status, "not-persisted"); + assert.equal(calls, 2); + }); +} +test("label removal permits only the expected copied parentId rechain", () => { + const f = fixture(); + const current = f.integration.start(f.manager, f.append, { + reason: "startup", + }); + current.attachment.bind("base", {}); + flush(f.manager); + const base = f.manager.getLeafId(); + assert.ok(base); + f.manager.appendLabelChange(base, "fixture-label"); + current.attachment.bind("selected", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const original = f.manager.getEntry(leaf)!; + assert.equal( + f.integration.beforeFork(f.manager, { entryId: leaf, position: "at" }).status, + "captured", + ); + const copy = persistedCopy(f.manager, leaf); + assert.notEqual(copy.getEntry(leaf)?.parentId, original.parentId); + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: copy.getSessionFile(), + }); + const restored = createSessionProfileIntegration().start( + copy, + (t, d) => { + copy.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile: f.manager.getSessionFile() }, + ); + name(restored.outcome, "selected"); +}); +test("actual fork path excludes failed sibling and never imports its quarantine", () => { + const f = fixture(); + let fail = false; + const live = f.integration.start( + f.manager, + (t, d) => { + f.append(t, d); + if (fail) throw new Error("injected failure"); + }, + { reason: "startup" }, + ); + live.attachment.bind("base", {}); + flush(f.manager); + const base = f.manager.getLeafId(); + assert.ok(base); + fail = true; + live.attachment.bind("failed-left", {}); + const failed = f.manager.getLeafId(); + fail = false; + f.manager.branch(base); + live.attachment.bind("right", {}); + const right = f.manager.getLeafId(); + assert.ok(right); + assert.notEqual(right, failed); + assert.equal( + f.integration.beforeFork(f.manager, { entryId: right, position: "at" }) + .status, + "captured", + ); + const copy = persistedCopy(f.manager, right); + assert.equal( + copy.getBranch().some((e) => e.id === failed), + false, + ); + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: copy.getSessionFile(), + }); + name( + createSessionProfileIntegration().start( + copy, + (t, d) => { + copy.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile: f.manager.getSessionFile() }, + ).outcome, + "right", + ); +}); +for (const changed of ["data", "timestamp", "parentId", "extra"] as const) { + test(`copied same-ID ${changed} mutation fails closed without blessing destination disk`, () => { + const f = fixture(); + const live = f.integration.start(f.manager, f.append, { reason: "startup" }); + flush(f.manager); + live.attachment.bind("trusted", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + assert.equal( + f.integration.beforeFork(f.manager, { entryId: leaf, position: "at" }) + .status, + "captured", + ); + const copy = persistedCopy(f.manager, leaf); + const row = copy.getEntry(leaf)! as unknown as Record; + if (changed === "data") row.data.name = "FAILED"; + if (changed === "timestamp") row.timestamp = "changed"; + if (changed === "parentId") row.parentId = "unexpected-parent"; + if (changed === "extra") row.extra = { changed: true }; + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: copy.getSessionFile(), + }); + const current = createSessionProfileIntegration({ + // Inject matching disk content as in F2, not ordinary Pi ID reuse. + readFile: (path) => + path === copy.getSessionFile() + ? [copy.getHeader(), ...copy.getEntries()] + .map((e) => JSON.stringify(e)) + .join("\n") + : readFileSync(path, "utf8"), + }).start( + copy, + (t, d) => { + copy.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile: f.manager.getSessionFile() }, + ); + assert.equal(current.outcome.status, "indeterminate"); + assert.equal(current.attachment.clear().status, "persisted"); + copy.branch(leaf); + assert.equal(current.attachment.refresh().status, "indeterminate"); + // Structural source + injected matching disk, never a production rewrite. + }); +} +test("in-memory confirmed fork keeps same-manager evidence but never restores pending state", () => { + const f = fixture(true); + const live = f.integration.start(f.manager, f.append, { reason: "startup" }); + live.attachment.bind("pending", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + assert.equal( + f.integration.beforeFork(f.manager, { entryId: leaf, position: "at" }).status, + "captured", + ); + f.integration.shutdown(f.manager, { reason: "fork" }); + f.manager.createBranchedSession(leaf); + const current = createSessionProfileIntegration().start(f.manager, f.append, { + reason: "fork", + }); + assert.equal(current.outcome.status, "indeterminate"); + assert.equal( + current.attachment.bind("new-explicit", {}).status, + "not-persisted", + ); + assert.equal(live.attachment.clear().status, "indeterminate"); +}); +test("fork before root creates empty child without parent binding or established-history transfer", () => { + const f = fixture(); + flush(f.manager); + const firstUser = f.manager.getLeafId(); + assert.ok(firstUser); + const live = f.integration.start(f.manager, f.append, { reason: "startup" }); + live.attachment.bind("parent", {}); + assert.equal( + f.integration.beforeFork(f.manager, { + entryId: firstUser, + position: "before", + }).status, + "captured", + ); + const previousSessionFile = f.manager.getSessionFile(); + const child = SessionManager.create( + f.manager.getCwd(), + f.manager.getSessionDir(), + ); + child.newSession({ parentSession: previousSessionFile }); + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: child.getSessionFile(), + }); + const current = createSessionProfileIntegration().start( + child, + (t, d) => { + child.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile }, + ); + assert.equal(current.outcome.status, "indeterminate"); + assert.equal(current.attachment.bind("child", {}).status, "not-persisted"); +}); +test("distinct manager cannot inherit another manager's pending activation", () => { + const f = fixture(); + f.integration + .start(f.manager, f.append, { reason: "startup" }) + .attachment.bind("pending", {}); + const other = fixture(); + assert.equal( + createSessionProfileIntegration().start(other.manager, other.append, { + reason: "startup", + }).outcome.status, + "indeterminate", + ); +}); +test("valid cancelled before-user fork leaves disk, public entries and explicit binding unchanged", () => { + const f = fixture(); + flush(f.manager); + const user = f.manager.getLeafId(); + assert.ok(user); + let calls = 0; + const live = f.integration.start( + f.manager, + (t, d) => { + calls++; + f.append(t, d); + }, + { reason: "startup" }, + ); + live.attachment.bind("current", {}); + const entries = JSON.stringify(f.manager.getEntries()); + const disk = readFileSync(f.manager.getSessionFile()!, "utf8"); + assert.equal( + f.integration.beforeFork(f.manager, { entryId: user, position: "before" }) + .status, + "captured", + ); + assert.equal(JSON.stringify(f.manager.getEntries()), entries); + assert.equal(readFileSync(f.manager.getSessionFile()!, "utf8"), disk); + name(live.attachment.refresh(), "current"); + assert.equal(calls, 1); +}); +test("public runtime rejects unsaved persisted fork before teardown and preserves explicit selection", async () => { + const f = fixture(); + const live = f.integration.start(f.manager, f.append, { reason: "startup" }); + live.attachment.bind("pending", {}); + const leaf = f.manager.getLeafId(); + assert.ok(leaf); + const before = JSON.stringify(f.manager.getEntries()); + let teardown = 0, + creations = 0; + const host = { + sessionManager: f.manager, + sessionFile: f.manager.getSessionFile(), + extensionRunner: { + hasHandlers: () => true, + emit: async (event: { entryId: string; position: "before" | "at" }) => { + assert.equal(f.integration.beforeFork(f.manager, event).status, "captured"); + }, + }, + abort: async () => { + teardown++; + }, + dispose: () => { + teardown++; + }, + }; + const runtime = new AgentSessionRuntime( + host as unknown as ConstructorParameters[0], + { cwd: f.manager.getCwd() } as ConstructorParameters< + typeof AgentSessionRuntime + >[1], + async () => { + creations++; + throw new Error("fixture must never create runtime"); + }, + ); + await assert.rejects(runtime.fork(leaf, { position: "at" }), { + message: + "This session has not been saved yet. Send a message before cloning or forking it.", + }); + assert.equal(teardown, 0); + assert.equal(creations, 0); + assert.equal(JSON.stringify(f.manager.getEntries()), before); + assert.equal(existsSync(f.manager.getSessionFile()!), false); + assert.equal(live.attachment.refresh().status, "not-persisted"); +}); +for (const incompatible of [false, true]) { + test(`isolated process ${incompatible ? "rejects incompatible owner protocol" : "cannot inherit the live registry"}`, () => { + const root = mkdtempSync(join(tmpdir(), "profile-process-")); + const moduleUrl = new URL( + "../lib/session-profile-integration.ts", + import.meta.url, + ).href; + const script = ` + import assert from "node:assert/strict"; + import { SessionManager } from "@earendil-works/pi-coding-agent"; + import { createSessionProfileIntegration } from ${JSON.stringify(moduleUrl)}; + ${incompatible ? 'globalThis[Symbol.for("gentle-pi.session-profile.lifecycle")] = { version: 2 };' : ""} + const manager = SessionManager.inMemory(${JSON.stringify(root)}); + manager.appendCustomEntry("gentle-pi.session-profile/v1", { kind: "bind", origin: "user", name: "unwritten", modelProfiles: {} }); + const before = JSON.stringify(manager.getEntries()); let calls = 0; + const live = createSessionProfileIntegration().start(manager, () => { calls++; }, { reason: "startup" }); + assert.equal(live.outcome.status, "indeterminate"); + ${incompatible ? 'assert.equal(live.attachment.bind("rejected", {}).status, "indeterminate");' : ""} + assert.equal(JSON.stringify(manager.getEntries()), before); + process.stdout.write(JSON.stringify({ status: live.outcome.status, calls, unchanged: true })); + `; + const result = spawnSync( + process.execPath, + ["--experimental-strip-types", "--input-type=module", "-e", script], + { encoding: "utf8", cwd: process.cwd(), env: process.env }, + ); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + assert.equal( + result.stdout, + '{"status":"indeterminate","calls":0,"unchanged":true}', + ); + }); +} + +for (const position of ["before", "at"] as const) { + test(`fork boundary label supports ${position} without requiring its removed ID`, () => { + const f = fixture(); + flush(f.manager); + const live = f.integration.start(f.manager, f.append, { reason: "startup" }); + live.attachment.bind("retained", {}); + const retained = f.manager.getLeafId(); + assert.ok(retained); + f.manager.appendLabelChange(retained, "boundary"); + const label = f.manager.getLeafId(); + assert.ok(label); + let entryId = label; + if (position === "before") { + flush(f.manager); + entryId = f.manager.getLeafId()!; + } + assert.equal( + f.integration.beforeFork(f.manager, { entryId, position }).status, + "captured", + ); + const copy = persistedCopy(f.manager, label); + assert.equal(copy.getEntry(label), undefined); + f.integration.shutdown(f.manager, { + reason: "fork", + targetSessionFile: copy.getSessionFile(), + }); + const current = createSessionProfileIntegration().start( + copy, + (t, d) => { + copy.appendCustomEntry(t, d); + }, + { reason: "fork", previousSessionFile: f.manager.getSessionFile() }, + ); + name(current.outcome, "retained"); + }); +} From 890ab984aee6e675e4fd819683ee3c1945eaf085 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 11:19:18 -0600 Subject: [PATCH 3/3] test(profiles): cover session profile selection serialization helpers Refs #1064 --- tests/session-profile-integration.test.ts | 126 +++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/tests/session-profile-integration.test.ts b/tests/session-profile-integration.test.ts index 1f7195643..236a1c702 100644 --- a/tests/session-profile-integration.test.ts +++ b/tests/session-profile-integration.test.ts @@ -9,7 +9,12 @@ import { AgentSessionRuntime, SessionManager, } from "@earendil-works/pi-coding-agent"; -import { createSessionProfileIntegration } from "../lib/session-profile-integration.ts"; +import { + createSessionProfileIntegration, + readCurrentSessionProfileOutcome, + runCurrentSessionProfileSelection, + waitCurrentSessionProfileSelection, +} from "../lib/session-profile-integration.ts"; const version = JSON.parse( readFileSync( @@ -691,3 +696,122 @@ for (const position of ["before", "at"] as const) { name(current.outcome, "retained"); }); } + +// Selection serialization helpers: FIFO queue, revocation and settle semantics. +function started() { + const f = fixture(); + flush(f.manager); + f.integration.start(f.manager, f.append, { reason: "startup" }); + return f; +} +function gate() { + let open!: () => void; + const opened = new Promise((resolve) => { + open = resolve; + }); + return { open, opened }; +} + +test("queued selections run in FIFO order and wait settles only after the last one", async () => { + const f = started(); + const order: string[] = []; + const first = gate(); + const a = runCurrentSessionProfileSelection(f.manager, async () => { + await first.opened; + order.push("a"); + return "a"; + }); + const b = runCurrentSessionProfileSelection(f.manager, async () => { + order.push("b"); + return "b"; + }); + let waited = false; + const wait = waitCurrentSessionProfileSelection(f.manager).then(() => { + waited = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, []); + assert.equal(waited, false, "wait does not settle while a selection is pending"); + first.open(); + assert.deepEqual(await Promise.all([a, b]), ["a", "b"]); + await wait; + assert.deepEqual(order, ["a", "b"]); + assert.equal(waited, true); +}); + +test("a rejected selection reaches its caller and does not block the next one", async () => { + const f = started(); + const failed = runCurrentSessionProfileSelection(f.manager, async () => { + throw new Error("effect failed"); + }); + const next = runCurrentSessionProfileSelection(f.manager, async () => "next"); + await assert.rejects(failed, /effect failed/); + assert.equal(await next, "next"); + await waitCurrentSessionProfileSelection(f.manager); +}); + +test("shutdown while a selection is queued revokes it before its effect runs", async () => { + const f = started(); + const first = gate(); + let ran = false; + const holder = runCurrentSessionProfileSelection(f.manager, async (_attachment, current) => { + await first.opened; + return current(); + }); + const queued = runCurrentSessionProfileSelection(f.manager, async () => { + ran = true; + return "ran"; + }); + await new Promise((resolve) => setImmediate(resolve)); // the holder effect is now in flight + f.integration.shutdown(f.manager, { reason: "quit" }); + first.open(); + assert.equal(await holder, false, "current() turns false after shutdown"); + assert.equal(await queued, undefined, "the queued effect is skipped"); + assert.equal(ran, false); + assert.equal( + (readCurrentSessionProfileOutcome(f.manager)?.state as { reason?: string }).reason, + "unavailable-lifecycle", + ); +}); + +test("a reload start while a selection is queued revokes the old attachment", async () => { + const f = started(); + const first = gate(); + const holder = runCurrentSessionProfileSelection(f.manager, async (attachment, current) => { + await first.opened; + return { current: current(), outcome: attachment.bind("late", {}) }; + }); + await new Promise((resolve) => setImmediate(resolve)); // the holder effect is now in flight + f.integration.shutdown(f.manager, { reason: "reload" }); + f.integration.start(f.manager, f.append, { reason: "reload" }); + first.open(); + const result = await holder; + assert.equal(result?.current, false); + assert.notEqual(result?.outcome.status, "persisted", "the old attachment cannot append"); + assert.equal( + f.manager.getBranch().some((entry) => entry.type === "custom" && entry.customType?.startsWith("gentle-pi.session-profile/")), + false, + ); +}); + +test("a session file change while a selection waits makes current() false", async () => { + const f = started(); + const first = gate(); + const holder = runCurrentSessionProfileSelection(f.manager, async (_attachment, current) => { + await first.opened; + return current(); + }); + await new Promise((resolve) => setImmediate(resolve)); // the holder effect is now in flight + const original = f.manager.getSessionFile.bind(f.manager); + f.manager.getSessionFile = () => `${original()}.moved`; + first.open(); + assert.equal(await holder, false); + f.manager.getSessionFile = original; +}); + +test("helpers report no owner as undefined", async () => { + const f = fixture(); + assert.equal(readCurrentSessionProfileOutcome(f.manager), undefined); + assert.equal(await runCurrentSessionProfileSelection(f.manager, async () => "never"), undefined); + await waitCurrentSessionProfileSelection(f.manager); +});