From 82031879406e0ea1a686fbd505c928eba7de5ad9 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Wed, 7 Oct 2026 22:22:48 -0600 Subject: [PATCH 01/15] feat(profiles): add session profile v1 record decoder Part of #1064 (slice 3b-i, codec). Standalone decoder for the gentle-pi.session-profile/v1 custom entry: closed origin set, own-property checks, one invalid route invalidates the whole snapshot, unknown fields ignored, prototype keys kept as own data. No Pi API, disk access, Enter, startup or routing changes. Chain: main -> [this] decoder -> encoder + active-branch replay (next). Out of scope: encoder, replay, disk-reader corroboration, Enter wiring. --- docs/session-profile-format.md | 36 ++++ lib/session-profile-persistence.ts | 131 ++++++++++++++ tests/session-profile-persistence.test.ts | 203 ++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 docs/session-profile-format.md create mode 100644 lib/session-profile-persistence.ts create mode 100644 tests/session-profile-persistence.test.ts diff --git a/docs/session-profile-format.md b/docs/session-profile-format.md new file mode 100644 index 000000000..1fc67893a --- /dev/null +++ b/docs/session-profile-format.md @@ -0,0 +1,36 @@ +# Session profile record format + +`lib/session-profile-persistence.ts` defines the optional session profile v1 +record format and its decoder. Adding this module does not change Enter, +startup, routing, shared defaults, or live orchestrator behavior. + +## Payloads + +A custom entry uses `customType: "gentle-pi.session-profile/v1"` and one payload: + +```json +{"kind":"bind","origin":"user","name":"work","modelProfiles":{"worker":{"model":"provider/model"},"orchestrator":{"thinking":"high"}}} +{"kind":"clear"} +``` + +The decoder accepts the closed origin set `user`, `local`, `repo`, and +`global`; this does not implement inherited startup persistence or follow +mode. A bound empty snapshot is not a clear. Snapshots are detached, known +invalid fields reject the whole binding, and unknown extra fields carry no +routing meaning. Existing route normalization supports legacy model strings +and `effort`; valid `thinking` takes precedence. + +## Decoding one entry + +`readSessionProfileEntry` classifies one supplied entry. It neither reads disk +nor establishes that the entry was persisted: + +| Result | Meaning | +| --- | --- | +| `absent` | Not a profile-family custom entry. | +| `bound` | A detached, validated binding, including an empty snapshot. | +| `cleared` | An explicit clear; fallback is a later consumer's decision. | +| `invalid` | Invalid v1 data. | +| `unsupported` | Unknown profile-family identifier, regardless of payload. | + +The encoder and the active-branch replay rule are a subsequent slice. diff --git a/lib/session-profile-persistence.ts b/lib/session-profile-persistence.ts new file mode 100644 index 000000000..0a4dcccfb --- /dev/null +++ b/lib/session-profile-persistence.ts @@ -0,0 +1,131 @@ +// Standalone v1 codec decoder (gentle-shell#1064). +// No Pi API, disk access, shared binding store, or orchestrator application. +import { isValidProfileName } from "./agent-profiles.ts"; +import { + isThinkingLevel, + normalizeModelConfig, + normalizeModelId, + normalizeRoutingEntry, + type AgentModelConfig, + type AgentRoutingEntry, +} from "./model-routing-authority.ts"; + +export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1"; +const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/"; + +export type SessionProfileOrigin = "user" | "local" | "repo" | "global"; + +export interface SessionProfileBindPayload { + kind: "bind"; + origin: SessionProfileOrigin; + name: string; + modelProfiles: AgentModelConfig; +} + +export interface SessionProfileClearPayload { + kind: "clear"; +} + +/** Public structural entry shape only; ordinary messages need no custom fields. */ +export interface SessionProfileEntry { + type: string; + customType?: string; + data?: unknown; +} + +export type SessionProfileReadResult = + | { status: "absent" } + | { status: "bound"; binding: SessionProfileBindPayload } + | { status: "cleared" } + | { status: "invalid" } + | { status: "unsupported" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string): boolean { + return Object.hasOwn(value, key); +} + +function isOrigin(value: unknown): value is SessionProfileOrigin { + return ( + value === "user" || + value === "local" || + value === "repo" || + value === "global" + ); +} + +/** Validate known fields before the permissive routing helper can drop them. */ +function readRoute(value: unknown): AgentRoutingEntry | undefined { + if (typeof value === "string") return normalizeRoutingEntry(value); + if (!isRecord(value)) return undefined; + const route = value; + if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined) + return undefined; + if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking)) + return undefined; + if (hasOwn(route, "effort") && !isThinkingLevel(route.effort)) + return undefined; + // Project only own known fields. Unknown extras, even an unknown-only route, + // carry no routing meaning. Thinking wins over legacy effort as elsewhere. + const known = Object.fromEntries( + ["model", "thinking", "effort"] + .filter((key) => hasOwn(route, key)) + .map((key) => [key, route[key]]), + ); + const normalized = normalizeRoutingEntry(known); + if (!normalized) return undefined; + return Object.fromEntries( + Object.entries(normalized).filter(([, field]) => field !== undefined), + ); +} + +function readSnapshot(value: unknown): AgentModelConfig | undefined { + if (!isRecord(value)) return undefined; + const entries: Array<[string, AgentRoutingEntry]> = []; + for (const [name, rawRoute] of Object.entries(value)) { + // Reuse the existing safe agent-name policy without letting its forgiving + // whole-config normalization silently discard a bad route. A marker also + // works for __proto__: that helper's setter exposes the marker via lookup; + // our output below uses own data properties, never prototype setters. + const probe = normalizeModelConfig({ [name]: { model: "validation/model" } }); + if (probe?.[name]?.model !== "validation/model") return undefined; + const route = readRoute(rawRoute); + if (route === undefined) return undefined; + entries.push([name, route]); + } + return Object.fromEntries(entries); +} + +function readBind( + data: Record, +): SessionProfileBindPayload | undefined { + if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key))) + return undefined; + if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined; + const modelProfiles = readSnapshot(data.modelProfiles); + if (modelProfiles === undefined) return undefined; + return { kind: "bind", origin: data.origin, name: data.name, modelProfiles }; +} + +/** Unknown family identifiers are terminal unsupported states, regardless of data. */ +export function readSessionProfileEntry( + entry: SessionProfileEntry, +): SessionProfileReadResult { + if ( + entry.type !== "custom" || + typeof entry.customType !== "string" || + !entry.customType.startsWith(SESSION_PROFILE_FAMILY) + ) + return { status: "absent" }; + if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE) + return { status: "unsupported" }; + const data = entry.data; + if (!isRecord(data) || !hasOwn(data, "kind")) return { status: "invalid" }; + if (data.kind === "clear") return { status: "cleared" }; + if (data.kind !== "bind") return { status: "invalid" }; + const binding = readBind(data); + return binding ? { status: "bound", binding } : { status: "invalid" }; +} diff --git a/tests/session-profile-persistence.test.ts b/tests/session-profile-persistence.test.ts new file mode 100644 index 000000000..aa9803c96 --- /dev/null +++ b/tests/session-profile-persistence.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + SESSION_PROFILE_CUSTOM_TYPE, + readSessionProfileEntry, +} from "../lib/session-profile-persistence.ts"; + +const custom = (data: unknown, customType = SESSION_PROFILE_CUSTOM_TYPE) => ({ + type: "custom", + customType, + data, +}); +const bind = (modelProfiles: unknown = {}) => ({ + kind: "bind", + origin: "user", + name: "work", + modelProfiles, +}); + +// These are supplied entries, not evidence of persistence on disk. +test("custom type identifies the v1 session profile family", () => { + assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1"); +}); + +test("decoder stays strict about present undefined known fields", () => { + for (const route of [ + { model: undefined }, + { thinking: undefined }, + { effort: undefined }, + ]) { + assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), { + status: "invalid", + }); + } +}); + +test("bound empty snapshot is distinct from cleared", () => { + assert.deepEqual(readSessionProfileEntry(custom(bind())), { + status: "bound", + binding: bind(), + }); + assert.deepEqual(readSessionProfileEntry(custom({ kind: "clear" })), { + status: "cleared", + }); +}); + +test("reader accepts all four origins and ignores unknown payload fields", () => { + for (const origin of ["user", "local", "repo", "global"]) { + assert.deepEqual( + readSessionProfileEntry( + custom({ ...bind(), origin, extra: true, version: 99 }), + ), + { + status: "bound", + binding: { ...bind(), origin }, + }, + ); + } + assert.deepEqual( + readSessionProfileEntry(custom({ kind: "clear", extra: true })), + { status: "cleared" }, + ); +}); + +test("legacy strings and effort normalize while orchestrator remains in the snapshot", () => { + const result = readSessionProfileEntry( + custom( + bind({ + worker: " provider/model ", + reviewer: { effort: "high", ignored: true }, + orchestrator: { model: "provider/main", thinking: "low", effort: "max" }, + inheriting: {}, + }), + ), + ); + assert.deepEqual(result, { + status: "bound", + binding: bind({ + worker: { model: "provider/model" }, + reviewer: { thinking: "high" }, + orchestrator: { model: "provider/main", thinking: "low" }, + inheriting: {}, + }), + }); +}); + +test("missing required fields and invalid known meanings fail closed", () => { + const invalid: unknown[] = [ + null, + [], + {}, + { kind: "other" }, + { kind: "bind", name: "work", modelProfiles: {} }, + { ...bind(), origin: "future" }, + { ...bind(), name: "../work" }, + { ...bind(), name: "constructor" }, + { ...bind(), modelProfiles: undefined }, + { ...bind(), modelProfiles: [] }, + ]; + for (const data of invalid) { + assert.deepEqual(readSessionProfileEntry(custom(data)), { + status: "invalid", + }); + } +}); + +test("one invalid route invalidates the whole snapshot instead of silently dropping it", () => { + const routes: unknown[] = [ + null, + [], + 3, + "bad model", + { model: "bad model", thinking: "low" }, + { model: "provider/good", thinking: "extreme" }, + { effort: "extreme" }, + { thinking: "low", effort: "extreme" }, + { model: null }, + ]; + for (const route of routes) { + assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), { + status: "invalid", + }); + assert.deepEqual( + readSessionProfileEntry( + custom(bind({ good: "provider/good", worker: route })), + ), + { status: "invalid" }, + ); + } + assert.deepEqual(readSessionProfileEntry(custom(bind({ "bad role": {} }))), { + status: "invalid", + }); +}); + +test("unknown route fields are ignored, including an unknown-only route", () => { + assert.deepEqual( + readSessionProfileEntry(custom(bind({ worker: { future: true } }))), + { + status: "bound", + binding: bind({ worker: {} }), + }, + ); +}); + +test("hostile record keys remain own data without changing prototypes", () => { + const routes = JSON.parse( + '{"__proto__":{"model":"provider/safe"},"constructor":{},"prototype":{}}', + ); + const result = readSessionProfileEntry(custom(bind(routes))); + assert.equal(result.status, "bound"); + if (result.status !== "bound") return; + assert.equal( + Object.getPrototypeOf(result.binding.modelProfiles), + Object.prototype, + ); + assert.equal(Object.hasOwn(result.binding.modelProfiles, "__proto__"), true); + assert.deepEqual(result.binding.modelProfiles["__proto__"], { + model: "provider/safe", + }); +}); + +test("required fields cannot be supplied by prototypes", () => { + assert.deepEqual(readSessionProfileEntry(custom(Object.create(bind()))), { + status: "invalid", + }); +}); + +test("reader returns defensive snapshots", () => { + const entry = custom(bind({ worker: { model: "provider/first" } })); + const first = readSessionProfileEntry(entry); + assert.equal(first.status, "bound"); + if (first.status !== "bound") return; + first.binding.modelProfiles.worker.model = "provider/third"; + const second = readSessionProfileEntry(entry); + assert.equal(second.status, "bound"); + if (second.status === "bound") + assert.equal(second.binding.modelProfiles.worker.model, "provider/first"); +}); + +test("noncustom and unrelated custom entries are absent even with matching payloads", () => { + for (const entry of [ + { type: "message", customType: SESSION_PROFILE_CUSTOM_TYPE, data: bind() }, + { type: "message", message: { role: "user", content: "hello" } }, + custom(bind(), "other/v1"), + ]) + assert.deepEqual(readSessionProfileEntry(entry), { status: "absent" }); +}); + +test("family identifier, not payload version, determines support", () => { + for (const customType of [ + "gentle-pi.session-profile/v0", + "gentle-pi.session-profile/v99", + "gentle-pi.session-profile/future", + ]) { + assert.deepEqual(readSessionProfileEntry(custom(null, customType)), { + status: "unsupported", + }); + } + assert.deepEqual( + readSessionProfileEntry(custom(bind(), "gentle-pi.session-profile-other/v1")), + { status: "absent" }, + ); +}); From 67868a9ac1c3ec061f34dcfc61e261cebd6ba1bb Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Wed, 7 Oct 2026 22:44:46 -0600 Subject: [PATCH 02/15] refactor(profiles): validate snapshot agent names with the shared predicate Export isSafeAgentName from model-routing-authority and use it in the session profile decoder instead of probing normalizeModelConfig. Assert constructor and prototype keys stay own data in the hostile-keys test. Addresses the two CodeRabbit comments on #1918. --- lib/model-routing-authority.ts | 7 ++++++- lib/session-profile-persistence.ts | 12 +++++------- tests/session-profile-persistence.test.ts | 4 ++++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/model-routing-authority.ts b/lib/model-routing-authority.ts index ee2a30c02..eb4f08786 100644 --- a/lib/model-routing-authority.ts +++ b/lib/model-routing-authority.ts @@ -28,6 +28,11 @@ export type ModelConfigFileResult = export const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/; const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/; +/** Shared agent-name policy; the same test normalizeModelConfig applies per key. */ +export function isSafeAgentName(name: string): boolean { + return SAFE_AGENT_NAME_PATTERN.test(name); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -78,7 +83,7 @@ export function normalizeModelConfig(value: unknown): AgentModelConfig | undefin if (!isRecord(value)) return undefined; const cleaned: AgentModelConfig = {}; for (const [name, entryValue] of Object.entries(value)) { - if (!SAFE_AGENT_NAME_PATTERN.test(name)) continue; + if (!isSafeAgentName(name)) continue; const entry = normalizeRoutingEntry(entryValue); if (entry) cleaned[name] = entry; } diff --git a/lib/session-profile-persistence.ts b/lib/session-profile-persistence.ts index 0a4dcccfb..3de8137de 100644 --- a/lib/session-profile-persistence.ts +++ b/lib/session-profile-persistence.ts @@ -2,8 +2,8 @@ // No Pi API, disk access, shared binding store, or orchestrator application. import { isValidProfileName } from "./agent-profiles.ts"; import { + isSafeAgentName, isThinkingLevel, - normalizeModelConfig, normalizeModelId, normalizeRoutingEntry, type AgentModelConfig, @@ -86,12 +86,10 @@ function readSnapshot(value: unknown): AgentModelConfig | undefined { if (!isRecord(value)) return undefined; const entries: Array<[string, AgentRoutingEntry]> = []; for (const [name, rawRoute] of Object.entries(value)) { - // Reuse the existing safe agent-name policy without letting its forgiving - // whole-config normalization silently discard a bad route. A marker also - // works for __proto__: that helper's setter exposes the marker via lookup; - // our output below uses own data properties, never prototype setters. - const probe = normalizeModelConfig({ [name]: { model: "validation/model" } }); - if (probe?.[name]?.model !== "validation/model") return undefined; + // Shared agent-name policy, applied here without the forgiving whole-config + // normalization that would silently discard a bad route. Output below uses + // own data properties, never prototype setters. + if (!isSafeAgentName(name)) return undefined; const route = readRoute(rawRoute); if (route === undefined) return undefined; entries.push([name, route]); diff --git a/tests/session-profile-persistence.test.ts b/tests/session-profile-persistence.test.ts index aa9803c96..214e94d81 100644 --- a/tests/session-profile-persistence.test.ts +++ b/tests/session-profile-persistence.test.ts @@ -157,6 +157,10 @@ test("hostile record keys remain own data without changing prototypes", () => { assert.deepEqual(result.binding.modelProfiles["__proto__"], { model: "provider/safe", }); + for (const key of ["constructor", "prototype"]) { + assert.equal(Object.hasOwn(result.binding.modelProfiles, key), true); + assert.deepEqual(result.binding.modelProfiles[key], {}); + } }); test("required fields cannot be supplied by prototypes", () => { From 732a11f1bd5e67ef653ff75904a48874f3caf9c9 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Wed, 7 Oct 2026 22:23:36 -0600 Subject: [PATCH 03/15] feat(profiles): add session profile encoder and active-branch replay Part of #1064 (slice 3b-i, codec). Adds createSessionProfileBind and createSessionProfileClear (explicit user selections only, typed undefined model/thinking omitted, effort stays strict) and replaySessionProfileBranch: the newest profile-family entry on a caller-supplied, already disk-corroborated active branch is terminal, including invalid and unsupported, and never revives an older binding. Still no disk access, Pi API, Enter or routing changes. Chain: main -> decoder (previous) -> [this] encoder + replay. Depends on: feat/1064-3b-i-1-codec-decoder. Out of scope: disk-reader corroboration, Enter wiring, guards. --- docs/session-profile-format.md | 29 +++-- lib/session-profile-persistence.ts | 70 ++++++++++- tests/session-profile-persistence.test.ts | 137 +++++++++++++++++++++- 3 files changed, 212 insertions(+), 24 deletions(-) diff --git a/docs/session-profile-format.md b/docs/session-profile-format.md index 1fc67893a..4fec0f18f 100644 --- a/docs/session-profile-format.md +++ b/docs/session-profile-format.md @@ -1,7 +1,7 @@ # Session profile record format `lib/session-profile-persistence.ts` defines the optional session profile v1 -record format and its decoder. Adding this module does not change Enter, +codec and pure replay contract. Adding this module does not change Enter, startup, routing, shared defaults, or live orchestrator behavior. ## Payloads @@ -13,24 +13,27 @@ A custom entry uses `customType: "gentle-pi.session-profile/v1"` and one payload {"kind":"clear"} ``` -The decoder accepts the closed origin set `user`, `local`, `repo`, and -`global`; this does not implement inherited startup persistence or follow -mode. A bound empty snapshot is not a clear. Snapshots are detached, known -invalid fields reject the whole binding, and unknown extra fields carry no -routing meaning. Existing route normalization supports legacy model strings -and `effort`; valid `thinking` takes precedence. +The encoder creates only explicit `user` selections. The decoder accepts the +closed origin set `user`, `local`, `repo`, and `global`; this does not implement +inherited startup persistence or follow mode. A bound empty snapshot is not a +clear. Snapshots are detached, known invalid fields reject the whole binding, +and unknown extra fields carry no routing meaning. Existing route normalization +supports legacy model strings and `effort`; valid `thinking` takes precedence. -## Decoding one entry +## Replay -`readSessionProfileEntry` classifies one supplied entry. It neither reads disk -nor establishes that the entry was persisted: +`replaySessionProfileBranch` requires entries already corroborated on disk, +ordered oldest to newest on the active branch. Supplying `getBranch()` alone +does not establish persistence. The newest profile-family entry is terminal: | Result | Meaning | | --- | --- | -| `absent` | Not a profile-family custom entry. | +| `absent` | No profile-family entry on the supplied branch. | | `bound` | A detached, validated binding, including an empty snapshot. | | `cleared` | An explicit clear; fallback is a later consumer's decision. | -| `invalid` | Invalid v1 data. | +| `invalid` | Invalid v1 data; never resurrect an earlier binding. | | `unsupported` | Unknown profile-family identifier, regardless of payload. | -The encoder and the active-branch replay rule are a subsequent slice. +Replay neither appends nor publishes bindings and never applies a model or +thinking level. A subsequent disk-reader slice supplies corroboration; this +codec alone cannot establish it. diff --git a/lib/session-profile-persistence.ts b/lib/session-profile-persistence.ts index 3de8137de..4585d6ea1 100644 --- a/lib/session-profile-persistence.ts +++ b/lib/session-profile-persistence.ts @@ -1,4 +1,4 @@ -// Standalone v1 codec decoder (gentle-shell#1064). +// Standalone v1 codec and pure active-branch replay (gentle-shell#1064). // No Pi API, disk access, shared binding store, or orchestrator application. import { isValidProfileName } from "./agent-profiles.ts"; import { @@ -40,6 +40,12 @@ export type SessionProfileReadResult = | { status: "invalid" } | { status: "unsupported" }; +export type SessionProfileReplayResult = + | { status: "absent" } + | (Exclude & { + entryIndex: number; + }); + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -58,10 +64,22 @@ function isOrigin(value: unknown): value is SessionProfileOrigin { } /** Validate known fields before the permissive routing helper can drop them. */ -function readRoute(value: unknown): AgentRoutingEntry | undefined { +function readRoute( + value: unknown, + encoderInput = false, +): AgentRoutingEntry | undefined { if (typeof value === "string") return normalizeRoutingEntry(value); if (!isRecord(value)) return undefined; - const route = value; + // Typed normalized routes can own optional undefined fields. Omit only those + // encoder fields; persisted fields and legacy effort retain strict validation. + const route = encoderInput + ? Object.fromEntries( + Object.entries(value).filter( + ([key, field]) => + !((key === "model" || key === "thinking") && field === undefined), + ), + ) + : value; if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined) return undefined; if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking)) @@ -82,7 +100,10 @@ function readRoute(value: unknown): AgentRoutingEntry | undefined { ); } -function readSnapshot(value: unknown): AgentModelConfig | undefined { +function readSnapshot( + value: unknown, + encoderInput = false, +): AgentModelConfig | undefined { if (!isRecord(value)) return undefined; const entries: Array<[string, AgentRoutingEntry]> = []; for (const [name, rawRoute] of Object.entries(value)) { @@ -90,7 +111,7 @@ function readSnapshot(value: unknown): AgentModelConfig | undefined { // normalization that would silently discard a bad route. Output below uses // own data properties, never prototype setters. if (!isSafeAgentName(name)) return undefined; - const route = readRoute(rawRoute); + const route = readRoute(rawRoute, encoderInput); if (route === undefined) return undefined; entries.push([name, route]); } @@ -99,15 +120,30 @@ function readSnapshot(value: unknown): AgentModelConfig | undefined { function readBind( data: Record, + encoderInput = false, ): SessionProfileBindPayload | undefined { if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key))) return undefined; if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined; - const modelProfiles = readSnapshot(data.modelProfiles); + const modelProfiles = readSnapshot(data.modelProfiles, encoderInput); if (modelProfiles === undefined) return undefined; return { kind: "bind", origin: data.origin, name: data.name, modelProfiles }; } +/** Create a detached user selection payload; never appends or claims durability. */ +export function createSessionProfileBind( + name: string, + modelProfiles: AgentModelConfig, +): SessionProfileBindPayload { + const binding = readBind({ origin: "user", name, modelProfiles }, true); + if (!binding) throw new TypeError("Invalid session profile binding"); + return binding; +} + +export function createSessionProfileClear(): SessionProfileClearPayload { + return { kind: "clear" }; +} + /** Unknown family identifiers are terminal unsupported states, regardless of data. */ export function readSessionProfileEntry( entry: SessionProfileEntry, @@ -127,3 +163,25 @@ export function readSessionProfileEntry( const binding = readBind(data); return binding ? { status: "bound", binding } : { status: "invalid" }; } + +/** + * Caller MUST supply ALREADY disk-corroborated active-branch entries, in branch + * order (oldest to newest). This function cannot establish that precondition: + * it does not read disk, call Pi, enforce append-failure recovery, establish + * durability, or update the shared map. In-memory getBranch() alone is NOT + * corroboration. Sibling and future paths must be excluded by the caller. + * + * The newest family entry wins even when invalid or unsupported; never revive + * an older binding. Cleared permits current fallback at a later consumer, + * whereas invalid/unsupported must remain distinct from absent. entryIndex is + * the actual supplied branch offset for a later adapter's location warning. + */ +export function replaySessionProfileBranch( + entries: readonly SessionProfileEntry[], +): SessionProfileReplayResult { + for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) { + const result = readSessionProfileEntry(entries[entryIndex]); + if (result.status !== "absent") return { ...result, entryIndex }; + } + return { status: "absent" }; +} diff --git a/tests/session-profile-persistence.test.ts b/tests/session-profile-persistence.test.ts index 214e94d81..96836146c 100644 --- a/tests/session-profile-persistence.test.ts +++ b/tests/session-profile-persistence.test.ts @@ -2,9 +2,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { SESSION_PROFILE_CUSTOM_TYPE, + createSessionProfileBind, + createSessionProfileClear, readSessionProfileEntry, + replaySessionProfileBranch, } from "../lib/session-profile-persistence.ts"; +import { normalizeModelConfig } from "../lib/model-routing-authority.ts"; +import { + normalizeProfilesFile, + PROFILES_KIND, + PROFILES_VERSION, +} from "../lib/agent-profiles.ts"; + const custom = (data: unknown, customType = SESSION_PROFILE_CUSTOM_TYPE) => ({ type: "custom", customType, @@ -17,9 +27,46 @@ const bind = (modelProfiles: unknown = {}) => ({ modelProfiles, }); -// These are supplied entries, not evidence of persistence on disk. -test("custom type identifies the v1 session profile family", () => { +// These are supplied branch entries, not evidence of persistence on disk. +test("writer emits only the v1 user binding fields and a minimal clear", () => { assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1"); + assert.deepEqual(createSessionProfileBind("work", {}), bind()); + assert.deepEqual(createSessionProfileClear(), { kind: "clear" }); +}); + +test("writer accepts real normalized optional fields without persisting undefined", () => { + const raw = { + worker: "provider/model", + reviewer: { effort: "high" }, + orchestrator: { model: "provider/main" }, + inheriting: {}, + }; + const routes = normalizeModelConfig(raw); + const profiles = normalizeProfilesFile({ + kind: PROFILES_KIND, + version: PROFILES_VERSION, + profiles: { work: raw }, + }); + assert.ok(routes); + assert.ok(profiles); + assert.equal(Object.hasOwn(routes.reviewer, "model"), true); + assert.equal(Object.hasOwn(routes.reviewer, "thinking"), true); + const expected = bind({ + worker: { model: "provider/model" }, + reviewer: { thinking: "high" }, + orchestrator: { model: "provider/main" }, + inheriting: {}, + }); + for (const config of [routes, profiles.file.profiles.work]) { + const payload = createSessionProfileBind("work", config); + assert.deepEqual(payload, expected); + assert.deepEqual(readSessionProfileEntry(custom(payload)), { + status: "bound", + binding: expected, + }); + assert.notEqual(payload.modelProfiles.reviewer, config.reviewer); + } + assert.equal(Object.hasOwn(routes.reviewer, "model"), true); }); test("decoder stays strict about present undefined known fields", () => { @@ -34,11 +81,12 @@ test("decoder stays strict about present undefined known fields", () => { } }); -test("bound empty snapshot is distinct from cleared", () => { +test("bound empty snapshot is distinct from absent and cleared", () => { assert.deepEqual(readSessionProfileEntry(custom(bind())), { status: "bound", binding: bind(), }); + assert.deepEqual(replaySessionProfileBranch([]), { status: "absent" }); assert.deepEqual(readSessionProfileEntry(custom({ kind: "clear" })), { status: "cleared", }); @@ -169,8 +217,32 @@ test("required fields cannot be supplied by prototypes", () => { }); }); -test("reader returns defensive snapshots", () => { - const entry = custom(bind({ worker: { model: "provider/first" } })); +test("writer rejects invalid input rather than manufacturing an empty snapshot", () => { + assert.throws(() => createSessionProfileBind("../bad", {})); + assert.throws(() => + createSessionProfileBind("work", { worker: { model: "bad model" } }), + ); + for (const route of [ + { model: null }, + { model: "bad model", thinking: undefined }, + { model: undefined, thinking: "extreme" }, + { effort: undefined }, + ]) { + assert.throws(() => + createSessionProfileBind("work", { + good: { model: "provider/good" }, + worker: route, + } as Parameters[1]), + ); + } +}); + +test("writer and reader return defensive snapshots", () => { + const source = { worker: { model: "provider/first" } }; + const payload = createSessionProfileBind("work", source); + source.worker.model = "provider/second"; + assert.equal(payload.modelProfiles.worker.model, "provider/first"); + const entry = custom(payload); const first = readSessionProfileEntry(entry); assert.equal(first.status, "bound"); if (first.status !== "bound") return; @@ -190,6 +262,27 @@ test("noncustom and unrelated custom entries are absent even with matching paylo assert.deepEqual(readSessionProfileEntry(entry), { status: "absent" }); }); +test("newest family entry is terminal and retains its actual branch index", () => { + const old = custom(bind({ worker: "provider/old" })); + const message = { + type: "message", + message: { role: "user", content: "hello" }, + }; + for (const [entry, status] of [ + [custom({ kind: "clear" }), "cleared"], + [custom({ kind: "bind" }), "invalid"], + [custom(bind(), "gentle-pi.session-profile/v2"), "unsupported"], + ] as const) { + assert.deepEqual( + replaySessionProfileBranch([old, message, entry, custom({}, "other/v1")]), + { + status, + entryIndex: 2, + }, + ); + } +}); + test("family identifier, not payload version, determines support", () => { for (const customType of [ "gentle-pi.session-profile/v0", @@ -205,3 +298,37 @@ test("family identifier, not payload version, determines support", () => { { status: "absent" }, ); }); + +test("branch order selects latest bind; clear and bad entries can be explicitly superseded", () => { + const latest = custom(bind({ worker: "provider/new" })); + for (const earlier of [ + custom({ kind: "clear" }), + custom(null), + custom(null, "gentle-pi.session-profile/v2"), + ]) { + assert.deepEqual(replaySessionProfileBranch([earlier, latest]), { + status: "bound", + entryIndex: 1, + binding: bind({ worker: { model: "provider/new" } }), + }); + } +}); + +test("replay sees only caller-supplied active branch, not siblings or later paths", () => { + const ancestor = custom(bind()); + const sibling = custom({ kind: "clear" }); + const future = custom(null, "gentle-pi.session-profile/v2"); + assert.equal( + replaySessionProfileBranch([ancestor, sibling]).status, + "cleared", + ); + assert.equal( + replaySessionProfileBranch([ancestor, future]).status, + "unsupported", + ); + assert.deepEqual(replaySessionProfileBranch([ancestor, { type: "message" }]), { + status: "bound", + entryIndex: 0, + binding: bind(), + }); +}); From 4d56f74b5573811bf589d5cc73f1683ed8c9db4d Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Wed, 7 Oct 2026 22:41:59 -0600 Subject: [PATCH 04/15] feat(profiles): add session profile disk corroboration reader Part of #1064 (slice 3b-i, disk reader). readSessionProfileDisk reads the session's public active branch and JSONL file, selects the newest profile-family entry (excluding known failed append IDs) and admits it only when the record on disk is byte-identical; otherwise it returns one indeterminate reason without record contents. No writes, fallback, cache, ancestry repair or fsync; a missing file never restores a memory-only profile. Verified against a real SessionManager session file. Chain: main -> decoder (#1918) -> encoder + replay (#1919) -> [this]. Depends on: #1919. Out of scope: append controller, authority publication, Enter wiring. --- docs/session-profile-format.md | 18 ++ lib/session-profile-disk-reader.ts | 171 +++++++++++++ tests/session-profile-disk-reader-pi.test.ts | 168 +++++++++++++ tests/session-profile-disk-reader.test.ts | 252 +++++++++++++++++++ 4 files changed, 609 insertions(+) create mode 100644 lib/session-profile-disk-reader.ts create mode 100644 tests/session-profile-disk-reader-pi.test.ts create mode 100644 tests/session-profile-disk-reader.test.ts diff --git a/docs/session-profile-format.md b/docs/session-profile-format.md index 4fec0f18f..8c1d5fb60 100644 --- a/docs/session-profile-format.md +++ b/docs/session-profile-format.md @@ -37,3 +37,21 @@ does not establish persistence. The newest profile-family entry is terminal: Replay neither appends nor publishes bindings and never applies a model or thinking level. A subsequent disk-reader slice supplies corroboration; this codec alone cannot establish it. + +## Disk corroboration + +`readSessionProfileDisk(source)` in `lib/session-profile-disk-reader.ts` is the +stateless synchronous check that supplies the corroboration replay requires. +It reads the session's public active branch and its JSONL file, selects the +newest profile-family entry on the branch (excluding caller-supplied known +failed append IDs), and admits it only when the record on disk is identical. +It performs no writes, fallback policy, caching, ancestry repair or fsync, and +a missing file is conservative: it never restores a profile found only in +memory. + +The result is the decoder result plus `entryIndex` and `lineNumber`, or +`indeterminate` with one reason: `missing-source`, `invalid-candidate-metadata`, +`unreadable-file`, `source-changed`, `invalid-json`, `invalid-record`, +`session-header-mismatch`, `duplicate-id`, `missing-header`, +`selected-record-missing`, `selected-record-mismatch`, or +`unserializable-source`. Reasons never carry record contents. diff --git a/lib/session-profile-disk-reader.ts b/lib/session-profile-disk-reader.ts new file mode 100644 index 000000000..04af43ddb --- /dev/null +++ b/lib/session-profile-disk-reader.ts @@ -0,0 +1,171 @@ +// Observable selected-record authority only, not conversation durability. +import { readFileSync } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; +import { + readSessionProfileEntry, + SESSION_PROFILE_CUSTOM_TYPE, + type SessionProfileReadResult, +} from "./session-profile-persistence.ts"; + +const profileFamily = SESSION_PROFILE_CUSTOM_TYPE.slice( + 0, + SESSION_PROFILE_CUSTOM_TYPE.lastIndexOf("/") + 1, +); + +export interface SessionProfileSource { + getSessionId(): string; + getSessionFile(): string | undefined; + getBranch(): readonly unknown[]; +} +export type DiskProfileResult = + | { + status: "indeterminate"; + reason: string; + entryIndex?: never; + lineNumber?: number; + } + | (SessionProfileReadResult & { entryIndex?: number; lineNumber?: number }); + +export interface DiskProfileOptions { + readFile?: (path: string) => string; + /** Trusted caller evidence ONLY: exclude known failed appends, never arbitrary + * invalid/future entries. This reader owns no quarantine or append controller. + * Failed IDs remain excluded even if they subsequently appear on disk. */ + knownFailedEntryIds?: ReadonlySet; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function metadata(value: Record): boolean { + return ( + ["type", "id", "parentId", "timestamp"].every((key) => + Object.hasOwn(value, key), + ) && + typeString(value.type) && + typeString(value.id) && + typeString(value.timestamp) && + (value.parentId === null || typeString(value.parentId)) + ); +} +function typeString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} +function snapshot(value: unknown): unknown { + // Same undefined omission/toJSON behavior as Pi's JSONL serialization. Throws + // on cycles/BigInt; decoded copies cannot retain mutable memory references. + try { + return JSON.parse(JSON.stringify(value)); + } catch { + throw new TypeError("Unserializable source snapshot"); + } +} +function unavailable(reason: string, lineNumber?: number): DiskProfileResult { + return lineNumber === undefined + ? { status: "indeterminate", reason } + : { status: "indeterminate", reason, lineNumber }; +} + +/** + * Stateless synchronous read of the captured public active branch and file. + * No writes, SDK imports, UI, fallback policy, cache, ancestry repair or fsync. + * An orphan selected record can be corroborated: complete disk ancestry is NOT + * required. No external-writer/TOCTOU guarantee is implied by source checks. + * Missing disk is conservative and does not decide future memory-only activation. + */ +export function readSessionProfileDisk( + source: SessionProfileSource, + options: DiskProfileOptions = {}, +): DiskProfileResult { + try { + const sessionId = source.getSessionId(); + const path = source.getSessionFile(); + const rawBranch = source.getBranch(); + if (!typeString(sessionId) || !typeString(path) || !Array.isArray(rawBranch)) + return unavailable("missing-source"); + // Validate own required candidate metadata before serialization can hide + // missing/inherited fields. Ordinary branch entries have no profile meaning. + for (const raw of rawBranch) { + if ( + record(raw) && + raw.type === "custom" && + typeof raw.customType === "string" && + raw.customType.startsWith(profileFamily) && + (!metadata(raw) || !Object.hasOwn(raw, "customType")) + ) + return unavailable("invalid-candidate-metadata"); + } + const branch = snapshot(rawBranch) as unknown[]; + const failed = new Set(options.knownFailedEntryIds); + let entryIndex = -1; + for (let index = branch.length - 1; index >= 0; index--) { + const candidate = branch[index]; + if ( + record(candidate) && + candidate.type === "custom" && + typeof candidate.customType === "string" && + candidate.customType.startsWith(profileFamily) && + !failed.has(candidate.id as string) + ) { + entryIndex = index; + break; + } + } + let text: string; + try { + text = (options.readFile ?? ((file) => readFileSync(file, "utf8")))(path); + } catch { + return unavailable("unreadable-file"); + } + if ( + source.getSessionId() !== sessionId || + source.getSessionFile() !== path || + !isDeepStrictEqual(snapshot(source.getBranch()), branch) + ) + return unavailable("source-changed"); + const disk = new Map< + string, + { value: Record; lineNumber: number } + >(); + let foundHeader = false; + const lines = text.split("\n"); + for (let index = 0; index < lines.length; index++) { + if (!lines[index].trim()) continue; + const lineNumber = index + 1; + let value: unknown; + try { + value = JSON.parse(lines[index]); + } catch { + return unavailable("invalid-json", lineNumber); + } + if (!record(value)) return unavailable("invalid-record", lineNumber); + if (!foundHeader) { + if (value.type !== "session" || value.id !== sessionId) + return unavailable("session-header-mismatch", lineNumber); + foundHeader = true; + continue; + } + if (!metadata(value) || value.type === "session") + return unavailable("invalid-record", lineNumber); + if (disk.has(value.id as string) || value.id === sessionId) + return unavailable("duplicate-id", lineNumber); + disk.set(value.id as string, { value, lineNumber }); + } + if (!foundHeader) return unavailable("missing-header"); + if (entryIndex < 0) return { status: "absent" }; + const selected = branch[entryIndex] as Record; + const saved = disk.get(selected.id as string); + if (!saved) return unavailable("selected-record-missing"); + if (!isDeepStrictEqual(selected, saved.value)) + return unavailable("selected-record-mismatch", saved.lineNumber); + const semantic = readSessionProfileEntry({ + type: selected.type as string, + customType: selected.customType as string, + data: selected.data, + }); + return { ...semantic, entryIndex, lineNumber: saved.lineNumber }; + } catch { + // Never expose exception messages or record contents (including getters). + return unavailable("unserializable-source"); + } +} diff --git a/tests/session-profile-disk-reader-pi.test.ts b/tests/session-profile-disk-reader-pi.test.ts new file mode 100644 index 000000000..362bc7494 --- /dev/null +++ b/tests/session-profile-disk-reader-pi.test.ts @@ -0,0 +1,168 @@ +// Run only in the parent's isolated snapshot with supported existing Pi deps. +// Public API fixtures, never providers or production flush/rollback workarounds. +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { fileURLToPath } from "node:url"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { readSessionProfileDisk } from "../lib/session-profile-disk-reader.ts"; +import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts"; + +const packagePath = join( + dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), + "..", + "package.json", +); +const version: string = JSON.parse(readFileSync(packagePath, "utf8")).version; +// Fail closed rather than silently skipping tests against stale feature deps. +assert.match(version, /^\d+\.\d+\.\d+$/); +assert.ok( + Number(version.split(".")[0]) >= 1, + `Pi >=1.0.0 required, installed ${version}`, +); +console.info( + `Public SessionManager fixture API: ${version}; minimum tested baseline 1.0.0`, +); + +function fixture(t: TestContext) { + const root = mkdtempSync(join(tmpdir(), "profile-reader-pi-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const sessions = join(root, "sessions"); + const manager = SessionManager.create(root, sessions); + return { manager, sessions }; +} +function bind(name: string) { + return { + kind: "bind", + origin: "user", + name, + modelProfiles: { worker: { model: `offline/${name}` } }, + }; +} +function flushFixture(manager: SessionManager) { + // An ordinary test-owned conversation message triggers Pi's normal persistence. + manager.appendMessage({ + role: "user", + content: "isolated reader fixture", + timestamp: Date.now(), + }); +} +function expectBound(manager: SessionManager, name: string) { + const result = readSessionProfileDisk(manager); + assert.equal(result.status, "bound"); + if (result.status !== "bound") + throw new Error("Expected corroborated binding"); + assert.equal(result.binding.name, name); +} + +test("custom-only public session cannot restore memory; ordinary fixture flush corroborates", (t) => { + const { manager } = fixture(t); + manager.appendCustomEntry(customType, bind("initial")); + const path = manager.getSessionFile(); + assert.ok(path); + assert.equal(existsSync(path), false); + assert.equal(readSessionProfileDisk(manager).status, "indeterminate"); + flushFixture(manager); + expectBound(manager, "initial"); +}); + +test("public selected branch, sibling and extracted session stay separated", (t) => { + const { manager, sessions } = fixture(t); + manager.appendCustomEntry(customType, bind("base")); + flushFixture(manager); + const base = manager.getLeafId(); + assert.ok(base); + manager.appendCustomEntry(customType, bind("left")); + const left = manager.getLeafId(); + assert.ok(left); + manager.branch(base); + manager.appendCustomEntry(customType, bind("right")); + expectBound(manager, "right"); + manager.branch(left); + expectBound(manager, "left"); + const originalFile = manager.getSessionFile(); + const extracted = manager.createBranchedSession(left); + assert.ok(extracted); + assert.notEqual(extracted, originalFile); + const reopened = SessionManager.open(extracted, sessions); + expectBound(reopened, "left"); + assert.equal( + reopened + .getBranch() + .some( + (row) => + row.type === "custom" && (row.data as { name?: string })?.name === "right", + ), + false, + ); +}); + +for (const recovery of ["bind", "clear"] as const) { + test(`real EISDIR ghost quarantined; new ${recovery} corroborates missing parent even after reopen`, (t) => { + const { manager, sessions } = fixture(t); + manager.appendCustomEntry(customType, bind("good")); + flushFixture(manager); + const file = manager.getSessionFile(); + assert.ok(file); + const saved = `${file}.saved`; + renameSync(file, saved); + mkdirSync(file); + assert.throws( + () => manager.appendCustomEntry(customType, bind("failed")), + (error: unknown) => { + assert.equal((error as NodeJS.ErrnoException).code, "EISDIR"); + return true; + }, + ); + const failedId = manager.getLeafId(); + assert.ok(failedId); + assert.equal(readSessionProfileDisk(manager).status, "indeterminate"); + renameSync(file, `${file}.fault-directory`); + renameSync(saved, file); + assert.equal(readSessionProfileDisk(manager).status, "indeterminate"); + const knownFailedEntryIds = new Set([failedId]); + const old = readSessionProfileDisk(manager, { knownFailedEntryIds }); + assert.equal(old.status, "bound"); + if (old.status === "bound") assert.equal(old.binding.name, "good"); + manager.appendCustomEntry( + customType, + recovery === "bind" ? bind("recovered") : { kind: "clear" }, + ); + const latest = manager.getBranch().at(-1); + assert.ok(latest); + assert.equal(latest.parentId, failedId); + const disk = readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal( + disk.some((row) => row.id === failedId), + false, + ); + assert.deepEqual( + disk.find((row) => row.id === latest.id), + JSON.parse(JSON.stringify(latest)), + ); + assert.equal( + readSessionProfileDisk(manager, { knownFailedEntryIds }).status, + recovery === "bind" ? "bound" : "cleared", + ); + const reopened = SessionManager.open(file, sessions); + assert.equal(reopened.getBranch().length, 1); + assert.equal(reopened.getBranch()[0].id, latest.id); + assert.equal( + readSessionProfileDisk(reopened).status, + recovery === "bind" ? "bound" : "cleared", + ); + if (recovery === "bind") expectBound(reopened, "recovered"); + }); +} diff --git a/tests/session-profile-disk-reader.test.ts b/tests/session-profile-disk-reader.test.ts new file mode 100644 index 000000000..29e33fe8c --- /dev/null +++ b/tests/session-profile-disk-reader.test.ts @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { readSessionProfileDisk } from "../lib/session-profile-disk-reader.ts"; +import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts"; + +const header = { type: "session", id: "session", version: 3 }; +function entry(id = "selected", data: unknown = { kind: "clear" }) { + return { + type: "custom", + id, + parentId: null, + timestamp: "2026-10-05T00:00:00Z", + customType, + data, + }; +} +function fixture( + t: TestContext, + branch: unknown[] = [], + rows: unknown[] = [header, ...branch], +) { + const root = mkdtempSync(join(tmpdir(), "profile-reader-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const path = join(root, "session.jsonl"); + writeFileSync( + path, + rows + .map((row) => (typeof row === "string" ? row : JSON.stringify(row))) + .join("\n") + "\n", + ); + const source = { + getSessionId: () => "session", + getSessionFile: (): string | undefined => path, + getBranch: () => branch, + }; + return { path, source, read: () => readSessionProfileDisk(source) }; +} + +test("header only establishes absence", (t) => { + assert.deepEqual(fixture(t).read(), { status: "absent" }); +}); + +test("ordinary branch without family remains absent despite disk sibling", (t) => { + const message = { + type: "message", + id: "message", + parentId: null, + timestamp: "fixture", + }; + assert.deepEqual( + fixture(t, [message], [header, message, entry("sibling")]).read(), + { status: "absent" }, + ); +}); + +test("omitted data is terminal invalid or unsupported, never older resurrection", (t) => { + for (const identifier of [customType, "gentle-pi.session-profile/v9"]) { + const selected = { ...entry(), customType: identifier, data: undefined }; + const result = fixture(t, [entry("older"), selected]).read(); + assert.equal( + result.status, + identifier === customType ? "invalid" : "unsupported", + ); + } +}); + +for (const [label, data, status] of [ + ["clear", { kind: "clear" }, "cleared"], + [ + "empty", + { kind: "bind", origin: "user", name: "chosen", modelProfiles: {} }, + "bound", + ], + ["invalid", { kind: "broken" }, "invalid"], +] as const) { + test(`corroborated ${label} retains semantic state and original positions`, (t) => { + const selected = entry("selected", data); + const message = { + type: "message", + id: "message", + parentId: null, + timestamp: "fixture", + }; + const f = fixture(t, [message, selected], [header, "", message, selected]); + const result = f.read(); + assert.equal(result.status, status); + assert.equal(result.entryIndex, 1); + assert.equal(result.lineNumber, 4); + }); +} + +test("future newest is terminal; sibling records never win", (t) => { + const older = entry("older"); + const future = { + ...entry("future"), + customType: "gentle-pi.session-profile/v2", + }; + const f = fixture( + t, + [older, future], + [header, older, future, entry("sibling")], + ); + assert.equal(f.read().status, "unsupported"); +}); + +test("unknown unwritten newest never falls back; trusted failed evidence excludes only its ID", (t) => { + const older = entry("older"); + const ghost = entry("ghost"); + const f = fixture(t, [older, ghost], [header, older]); + assert.equal(f.read().status, "indeterminate"); + const options = { knownFailedEntryIds: new Set(["ghost"]) }; + assert.equal(readSessionProfileDisk(f.source, options).status, "cleared"); + writeFileSync( + f.path, + [header, older, ghost].map((row) => JSON.stringify(row)).join("\n"), + ); + assert.equal(readSessionProfileDisk(f.source, options).entryIndex, 0); + assert.equal( + readSessionProfileDisk(f.source, { + knownFailedEntryIds: new Set(["unrelated"]), + }).entryIndex, + 1, + ); +}); + +for (const [label, rows] of [ + ["wrong session", [{ ...header, id: "other" }, entry()]], + ["wrong header", [{ ...header, type: "message" }, entry()]], + ["duplicate ID", [header, entry(), entry()]], + ["malformed", [header, "{secret"]], + ["truncated", [header, '{"type":']], + ["data mismatch", [header, entry("selected", { kind: "broken" })]], + ["parent mismatch", [header, { ...entry(), parentId: "other" }]], + ["metadata mismatch", [header, { ...entry(), timestamp: "other" }]], + ["custom type mismatch", [header, { ...entry(), customType: "other" }]], + ["extra metadata mismatch", [header, { ...entry(), extra: "changed" }]], + ["nonobject record", [header, "null"]], + ["empty file", []], + [ + "missing metadata", + [ + header, + { type: "custom", id: "selected", customType, data: { kind: "clear" } }, + ], + ], +] as const) { + test(`${label} is explicitly indeterminate`, (t) => { + const result = fixture(t, [entry()], [...rows]).read(); + assert.equal(result.status, "indeterminate"); + assert.ok(!JSON.stringify(result).includes("secret")); + }); +} + +test("parse failure reports physical line without contents", (t) => { + const result = fixture(t, [], [header, "", "bad private contents"]).read(); + assert.deepEqual(result, { + status: "indeterminate", + reason: "invalid-json", + lineNumber: 3, + }); +}); + +test("missing, undefined and unreadable paths are conservative", (t) => { + const f = fixture(t); + rmSync(f.path); + assert.equal(f.read().status, "indeterminate"); + f.source.getSessionFile = () => undefined; + assert.equal(f.read().status, "indeterminate"); + f.source.getSessionFile = () => f.path; + assert.equal( + readSessionProfileDisk(f.source, { + readFile: () => { + throw new Error("private"); + }, + }).status, + "indeterminate", + ); +}); + +for (const change of ["id", "path", "branch"] as const) { + test(`source ${change} change during read invalidates authority`, (t) => { + const f = fixture(t, [entry()]); + const result = readSessionProfileDisk(f.source, { + readFile: (path) => { + const text = readFileSync(path, "utf8"); + if (change === "id") f.source.getSessionId = () => "changed"; + if (change === "path") f.source.getSessionFile = () => "changed"; + if (change === "branch") f.source.getBranch().push(entry("changed")); + return text; + }, + }); + assert.equal(result.status, "indeterminate"); + }); +} + +test("JSON key order and omitted undefined follow serialization; orphan parent is allowed", (t) => { + const selected = { ...entry(), parentId: "missing-failed", extra: undefined }; + const reversed = Object.fromEntries(Object.entries(selected).reverse()); + assert.equal( + fixture(t, [selected], [header, reversed]).read().status, + "cleared", + ); +}); + +test("unserializable memory and malformed own candidate metadata fail closed", (t) => { + const circular: any = entry(); + circular.extra = circular; + assert.equal( + fixture(t, [circular], [header, entry()]).read().status, + "indeterminate", + ); + for (const field of ["id", "parentId", "timestamp"]) { + const candidate: any = entry(); + delete candidate[field]; + assert.equal( + fixture(t, [candidate], [header, candidate]).read().status, + "indeterminate", + ); + } +}); + +test("all origins, legacy routes and orchestrator are detached and preserved", (t) => { + for (const origin of ["user", "local", "repo", "global"]) { + const selected = entry("selected", { + kind: "bind", + origin, + name: "chosen", + modelProfiles: { + worker: "provider/model", + orchestrator: { effort: "high" }, + }, + }); + const f = fixture(t, [selected]); + const result = f.read(); + assert.equal(result.status, "bound"); + if (result.status !== "bound") throw new Error("expected binding"); + assert.equal(result.binding.origin, origin); + assert.deepEqual(result.binding.modelProfiles.orchestrator, { + thinking: "high", + }); + result.binding.modelProfiles.worker = { model: "changed" }; + const next = f.read(); + assert.equal(next.status, "bound"); + if (next.status === "bound") + assert.deepEqual(next.binding.modelProfiles.worker, { + model: "provider/model", + }); + } +}); From f48770f61d5f9fdf4735ecd1ddaf6d19719f4685 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Thu, 8 Oct 2026 12:40:41 -0600 Subject: [PATCH 05/15] feat(profiles): add session profile append controller with failure quarantine Refs #1064 --- lib/session-profile-append-controller.ts | 377 +++++++++++++ ...ssion-profile-append-controller-pi.test.ts | 247 ++++++++ .../session-profile-append-controller.test.ts | 530 ++++++++++++++++++ 3 files changed, 1154 insertions(+) create mode 100644 lib/session-profile-append-controller.ts create mode 100644 tests/session-profile-append-controller-pi.test.ts create mode 100644 tests/session-profile-append-controller.test.ts diff --git a/lib/session-profile-append-controller.ts b/lib/session-profile-append-controller.ts new file mode 100644 index 000000000..61282df1c --- /dev/null +++ b/lib/session-profile-append-controller.ts @@ -0,0 +1,377 @@ +// Observable append authority; no SDK, lifecycle seams or consumers. +import { readFileSync } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; +import { + readSessionProfileDisk, + type DiskProfileResult, + type SessionProfileSource, +} from "./session-profile-disk-reader.ts"; +import { + createSessionProfileBind, + createSessionProfileClear, + readSessionProfileEntry, + SESSION_PROFILE_CUSTOM_TYPE, + type SessionProfileBindPayload, + type SessionProfileClearPayload, +} from "./session-profile-persistence.ts"; + +type Entry = { + id: string; + type: string; + customType?: string; + data?: unknown; + [key: string]: unknown; +}; +type Capture = { session: string; file: string | undefined; branch: Entry[] }; +type Scope = { + failed: Set; + established: boolean; + ambiguous: boolean; + // Ambiguity may clear after exact recovery, but historical unknown or changed + // whole records remain untrusted on branch return. Source/session/file scope + // belongs to this controller and its enclosing scopes map. + uncertain: boolean; + certified: Map; + pending?: Entry; +}; +export type SessionProfileAppendOutcome = { + status: + | "persisted" + | "not-persisted" + | "append-failed-preserved" + | "indeterminate"; + state: DiskProfileResult; +}; +export interface SessionProfileAppendOptions { + readFile?: (path: string) => string; +} +export interface SessionProfileAttachment { + bind( + name: string, + models: SessionProfileBindPayload["modelProfiles"], + ): SessionProfileAppendOutcome; + clear(): SessionProfileAppendOutcome; + refresh(): SessionProfileAppendOutcome; +} +function detached(value: T): T { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + // Callers fail closed without exposing getter, toJSON or serialization errors. + throw new TypeError("Unserializable controller snapshot"); + } +} +function family(entry: Entry): boolean { + return ( + entry.type === "custom" && + typeof entry.customType === "string" && + entry.customType.startsWith("gentle-pi.session-profile/") + ); +} +function unavailable(reason: string): SessionProfileAppendOutcome { + return { status: "indeterminate", state: { status: "indeterminate", reason } }; +} +function sameScope(a: Capture, b: Capture): boolean { + return a.session === b.session && a.file === b.file; +} +function prefix(a: Entry[], b: Entry[]): boolean { + return b.length >= a.length && isDeepStrictEqual(a, b.slice(0, a.length)); +} +function noConversation(branch: Entry[]): boolean { + return !branch.some((entry) => entry.type === "message"); +} + +/** + * 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. + * Corroboration does not promise fsync, full ancestry or external-writer safety. + */ +export function createSessionProfileAppendController( + initialSource: SessionProfileSource, + initialAppendEntry: (customType: string, data: unknown) => void, + options: SessionProfileAppendOptions = {}, +) { + const scopes = new Map>(); + const source = initialSource; + const appendEntry = initialAppendEntry; + let active: Scope | undefined; + const readFile = + options.readFile ?? ((file: string) => readFileSync(file, "utf8")); + function capture(): Capture { + const session = source.getSessionId(), + file = source.getSessionFile(); + if (!session || (file !== undefined && (typeof file !== "string" || !file))) + throw new Error("source"); + const raw = source.getBranch(); + if (!Array.isArray(raw)) throw new Error("branch"); + // Match the reader's own-field requirements before JSON can hide inherited + // metadata or an entry's toJSON can manufacture otherwise missing fields. + for (const value of raw) { + if (value && typeof value === "object" && family(value as Entry)) { + const entry = value as Entry; + if ( + !["type", "id", "parentId", "timestamp", "customType"].every((key) => + Object.hasOwn(entry, key), + ) || + typeof entry.timestamp !== "string" || + !entry.timestamp || + (entry.parentId !== null && + (typeof entry.parentId !== "string" || !entry.parentId)) + ) + throw new Error("profile-metadata"); + } + } + const branch = detached(raw) as Entry[]; + if ( + branch.some( + (entry) => + !entry || + typeof entry !== "object" || + typeof entry.id !== "string" || + !entry.id, + ) + ) + throw new Error("metadata"); + if (new Set(branch.map((entry) => entry.id)).size !== branch.length) + throw new Error("duplicate"); + return { session, file, branch }; + } + function scope(c: Capture): Scope { + let files = scopes.get(c.session); + if (!files) { + files = new Map(); + scopes.set(c.session, files); + } + let state = files.get(c.file); + if (!state) { + state = { + failed: new Set(), + established: false, + ambiguous: false, + uncertain: false, + certified: new Map(), + }; + files.set(c.file, state); + } + if (active !== state) { + if (active) active.pending = undefined; + active = state; + } + return state; + } + function stable(c: Capture): boolean { + const now = capture(); + return sameScope(c, now) && isDeepStrictEqual(c.branch, now.branch); + } + function authority(c: Capture, s: Scope) { + let missing = c.file === undefined; + const state = readSessionProfileDisk(source, { + knownFailedEntryIds: s.failed, + readFile(file) { + try { + const text = readFile(file); + s.established = true; + return text; + } catch (error) { + missing = (error as NodeJS.ErrnoException)?.code === "ENOENT"; + if (!missing) s.established = true; + throw error; + } + }, + }); + // Accepted observable evidence, not proof of first flush. Only append's exact + // NEW record or this controller's still-exact pending selection can activate. + // A fresh refresh never restores older unwritten branch entries. + return { + state, + preflush: + missing && + !s.established && + !s.ambiguous && + s.failed.size === 0 && + noConversation(c.branch) && + stable(c), + }; + } + function selected(c: Capture, s: Scope): Entry | undefined { + return c.branch.findLast((entry) => family(entry) && !s.failed.has(entry.id)); + } + function uncertainSelection(c: Capture, s: Scope): boolean { + // Under unknown branch movement, even the raw newest family entry must be + // a separately corroborated recovery. Exclusion cannot revive an older + // certified selection when the newest entry's provenance is ambiguous. + const latest = c.branch.findLast(family); + return ( + s.uncertain && + (!latest || !isDeepStrictEqual(s.certified.get(latest.id), latest)) + ); + } + function refresh(): SessionProfileAppendOutcome { + try { + const c = capture(), + s = scope(c), + latest = selected(c, s); + if (s.pending && !isDeepStrictEqual(s.pending, latest)) + s.pending = undefined; + const observed = authority(c, s); + if (!stable(c)) { + s.pending = undefined; + return unavailable("source-changed"); + } + if (observed.state.status !== "indeterminate") { + s.pending = undefined; + if (s.ambiguous || uncertainSelection(c, s)) + return unavailable("ambiguous-append"); + return detached({ status: "persisted", state: observed.state }); + } + if (observed.preflush && s.pending) + return detached({ + status: "not-persisted", + state: readSessionProfileEntry(s.pending), + }); + s.pending = undefined; + return { status: "indeterminate", state: observed.state }; + } catch { + if (active) active.pending = undefined; + return unavailable("unobservable-source"); + } + } + function append( + payload: SessionProfileBindPayload | SessionProfileClearPayload, + ): SessionProfileAppendOutcome { + let before: Capture; + try { + before = capture(); + } catch { + if (active) active.pending = undefined; + return unavailable("unobservable-source"); + } + const s = scope(before); + s.pending = undefined; + let preflush: boolean; + try { + preflush = authority(before, s).preflush; + if (!stable(before)) return unavailable("source-changed"); + } catch { + return unavailable("unobservable-source"); + } + let threw = false; + try { + appendEntry(SESSION_PROFILE_CUSTOM_TYPE, detached(payload)); + } catch { + threw = true; + } + let after: Capture; + try { + after = capture(); + } catch { + s.ambiguous = true; + s.uncertain = true; + // Snapshot failure may still leave observable public ID evidence. Never + // invent an ID or move quarantine into an unconfirmed session/file. + if (threw) { + try { + if ( + source.getSessionId() === before.session && + source.getSessionFile() === before.file + ) { + const old = new Set(before.branch.map((entry) => entry.id)); + for (const value of source.getBranch()) { + if (value && typeof value === "object") { + const entry = value as Entry; + if ( + family(entry) && + typeof entry.id === "string" && + entry.id && + !old.has(entry.id) + ) + s.failed.add(entry.id); + } + } + } + } catch { + /* No manufactured evidence when public access itself fails. */ + } + } + return unavailable("unobservable-append"); + } + if (!sameScope(before, after)) { + s.ambiguous = true; + s.uncertain = true; + const changed = scope(after); + changed.ambiguous = true; + changed.uncertain = true; + changed.pending = undefined; + return unavailable("append-scope-changed"); + } + const oldIds = new Set(before.branch.map((entry) => entry.id)); + const added = after.branch.filter((entry) => !oldIds.has(entry.id)); + if (threw) { + for (const entry of added) if (family(entry)) s.failed.add(entry.id); + if (!prefix(before.branch, after.branch)) { + s.ambiguous = true; + s.uncertain = true; + } + try { + const observed = authority(after, s).state; + if ( + !stable(after) || + s.ambiguous || + uncertainSelection(after, s) || + observed.status === "indeterminate" + ) + return unavailable("append-failed-authority-unavailable"); + return detached({ status: "append-failed-preserved", state: observed }); + } catch { + return unavailable("append-failed-authority-unavailable"); + } + } + // Public branch evidence, never the callback's return value, identifies the + // one new selected profile record. Neither old nor mismatched records count. + const entry = added[0]; + if ( + !prefix(before.branch, after.branch) || + added.length !== 1 || + !entry || + after.branch.at(-1)?.id !== entry.id || + entry.type !== "custom" || + entry.customType !== SESSION_PROFILE_CUSTOM_TYPE || + !isDeepStrictEqual(entry.data, payload) || + s.failed.has(entry.id) + ) { + s.ambiguous = true; + s.uncertain = true; + return unavailable("append-record-ambiguous"); + } + try { + const observed = authority(after, s); + if (!stable(after)) return unavailable("source-changed"); + if ( + observed.state.status !== "indeterminate" && + observed.state.entryIndex === after.branch.length - 1 + ) { + s.ambiguous = false; + s.certified.set(entry.id, detached(entry)); + return detached({ status: "persisted", state: observed.state }); + } + // The new exact explicit record is locally witnessed, not restored memory. + if (preflush && observed.preflush) { + s.pending = detached(entry); + return detached({ + status: "not-persisted", + state: readSessionProfileEntry(entry), + }); + } + return unavailable("append-not-corroborated"); + } catch { + return unavailable("append-not-corroborated"); + } + } + return { + bind: (name, models) => append(createSessionProfileBind(name, models)), + clear: () => append(createSessionProfileClear()), + refresh, + } satisfies SessionProfileAttachment; +} diff --git a/tests/session-profile-append-controller-pi.test.ts b/tests/session-profile-append-controller-pi.test.ts new file mode 100644 index 000000000..74f2ecb09 --- /dev/null +++ b/tests/session-profile-append-controller-pi.test.ts @@ -0,0 +1,247 @@ +// Public SessionManager fixtures only. Retain isolated data for parent evidence. +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { + createSessionProfileBind, + SESSION_PROFILE_CUSTOM_TYPE, +} from "../lib/session-profile-persistence.ts"; +import { + createSessionProfileAppendController, + type SessionProfileAppendOutcome, +} from "../lib/session-profile-append-controller.ts"; + +const metadata = join( + dirname(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"))), + "..", + "package.json", +); +const version: string = JSON.parse(readFileSync(metadata, "utf8")).version; +assert.match(version, /^\d+\.\d+\.\d+$/); +assert.ok( + Number(version.split(".")[0]) >= 1, + `Public fixtures require Pi >=1.0.0, installed ${version}`, +); +console.info( + `Public append controller fixture API: ${version}; minimum tested baseline 1.0.0`, +); +function controller(manager: SessionManager, errors: unknown[] = []) { + // Extension appendEntry is void; the actual public manager's ID is ignored. + return createSessionProfileAppendController(manager, (type, data): void => { + try { + manager.appendCustomEntry(type, data); + } catch (error) { + errors.push(error); + throw error; + } + }); +} +function fixture() { + const root = mkdtempSync(join(tmpdir(), "profile-append-pi-")); + const sessions = join(root, "sessions"); + const manager = SessionManager.create(root, sessions); + const errors: unknown[] = []; + return { manager, sessions, errors, adapter: controller(manager, errors) }; +} +function flush(manager: SessionManager) { + // Ordinary test-owned conversation fixture, never a production workaround. + manager.appendMessage({ + role: "user", + content: "isolated append controller fixture", + timestamp: Date.now(), + }); +} +function bound(result: SessionProfileAppendOutcome, name: string) { + assert.equal(result.state.status, "bound"); + if (result.state.status !== "bound") + throw new Error("Expected bound authority"); + assert.equal(result.state.binding.name, name); + return result.state.binding; +} +for (const operation of ["bind", "clear"] as const) { + test(`public explicit custom-only ${operation} reports preflush, normal fixture flush upgrades exact record`, () => { + const { manager, adapter } = fixture(); + // isPersisted() is configuration, not a first-flush witness. + assert.equal(manager.isPersisted(), true); + const first = + operation === "bind" ? adapter.bind("initial", {}) : adapter.clear(); + assert.equal(first.status, "not-persisted"); + assert.equal(first.state.status, operation === "bind" ? "bound" : "cleared"); + const file = manager.getSessionFile(); + assert.ok(file); + assert.equal(existsSync(file), false); + assert.equal(adapter.refresh().status, "not-persisted"); + assert.equal(controller(manager).refresh().status, "indeterminate"); + const selected = manager.getBranch().at(-1); + assert.ok(selected); + flush(manager); + const persisted = adapter.refresh(); + assert.equal(persisted.status, "persisted"); + assert.equal( + persisted.state.status, + operation === "bind" ? "bound" : "cleared", + ); + const disk = readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.deepEqual( + disk.find((row) => row.id === selected.id), + JSON.parse(JSON.stringify(selected)), + ); + }); +} +for (const prior of ["bound", "invalid", "future"] as const) { + for (const operation of ["bind", "clear"] as const) { + test(`public NEW ${operation} over unwritten ${prior} is not restoration or first-flush proof`, () => { + const { manager } = fixture(); + manager.appendCustomEntry( + prior === "future" + ? "gentle-pi.session-profile/v2" + : SESSION_PROFILE_CUSTOM_TYPE, + prior === "bound" ? createSessionProfileBind("unwritten", {}) : {}, + ); + const file = manager.getSessionFile(); + assert.ok(file); + assert.equal(existsSync(file), false); + const adapter = controller(manager); + assert.equal(adapter.refresh().status, "indeterminate"); + const result = + operation === "bind" ? adapter.bind("NEW", {}) : adapter.clear(); + assert.equal(result.status, "not-persisted"); + assert.equal( + result.state.status, + operation === "bind" ? "bound" : "cleared", + ); + if (operation === "bind") bound(result, "NEW"); + assert.equal(adapter.refresh().status, "not-persisted"); + assert.equal(controller(manager).refresh().status, "indeterminate"); + const selected = manager.getBranch().at(-1); + assert.ok(selected); + flush(manager); + assert.equal(adapter.refresh().status, "persisted"); + assert.equal( + controller(manager).refresh().state.status, + operation === "bind" ? "bound" : "cleared", + ); + const disk = readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.deepEqual( + disk.find((row) => row.id === selected.id), + JSON.parse(JSON.stringify(selected)), + ); + }); + } +} +for (const failure of ["bind", "clear"] as const) { + for (const recovery of ["bind", "clear"] as const) { + test(`public EISDIR failed ${failure} never adopted; new ${recovery} survives orphan-parent reopen`, () => { + const { manager, sessions, adapter, errors } = fixture(); + assert.equal( + adapter.bind("good", { + worker: { model: "offline/good" }, + orchestrator: { model: "offline/preserved" }, + }).status, + "not-persisted", + ); + flush(manager); + bound(adapter.refresh(), "good"); + const file = manager.getSessionFile(); + assert.ok(file); + const saved = `${file}.saved`; + renameSync(file, saved); + mkdirSync(file); + const failed = + failure === "bind" ? adapter.bind("failed", {}) : adapter.clear(); + assert.equal(failed.status, "indeterminate"); + assert.equal(errors.length, 1); + assert.equal((errors[0] as NodeJS.ErrnoException).code, "EISDIR"); + const ghost = manager.getBranch().at(-1); + assert.ok(ghost); + assert.equal(ghost.type, "custom"); + // Recover fixture filesystem by moves, not history edits or private APIs. + renameSync(file, `${file}.fault-directory`); + renameSync(saved, file); + const preserved = adapter.refresh(); + assert.equal(preserved.status, "persisted"); + bound(preserved, "good"); + const branch = manager.getBranch(); + const good = branch.find( + (entry) => entry.type === "custom" && entry.id !== ghost.id, + ); + assert.ok(good); + manager.branch(good.id); + bound(adapter.refresh(), "good"); + manager.branch(ghost.id); + bound(adapter.refresh(), "good"); + const recovered = + recovery === "bind" ? adapter.bind("recovered", {}) : adapter.clear(); + assert.equal(recovered.status, "persisted"); + assert.equal( + recovered.state.status, + recovery === "bind" ? "bound" : "cleared", + ); + const latest = manager.getBranch().at(-1); + assert.ok(latest); + assert.equal(latest.parentId, ghost.id); + const disk = readFileSync(file, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal( + disk.some((row) => row.id === ghost.id), + false, + ); + assert.deepEqual( + disk.find((row) => row.id === latest.id), + JSON.parse(JSON.stringify(latest)), + ); + const reopened = SessionManager.open(file, sessions); + const restored = controller(reopened).refresh(); + assert.equal(restored.status, "persisted"); + assert.equal( + restored.state.status, + recovery === "bind" ? "bound" : "cleared", + ); + // Pi truncates the resumed branch at the missing failed parent. The new + // selected record is valid authority without history repair/full ancestry. + assert.equal(reopened.getBranch().length, 1); + assert.equal( + reopened.getBranch().some((entry) => entry.id === ghost.id), + false, + ); + assert.equal(reopened.getBranch().at(-1)?.id, latest.id); + manager.branch(ghost.id); + bound(adapter.refresh(), "good"); + }); + } +} +test("public sibling disk records never override the selected active branch", () => { + const { manager, adapter } = fixture(); + adapter.bind("base", {}); + flush(manager); + const base = manager.getLeafId(); + assert.ok(base); + assert.equal(adapter.bind("left", {}).status, "persisted"); + const left = manager.getLeafId(); + assert.ok(left); + manager.branch(base); + assert.equal(adapter.bind("right", {}).status, "persisted"); + bound(adapter.refresh(), "right"); + manager.branch(left); + bound(adapter.refresh(), "left"); + const other = fixture(); + assert.equal(other.adapter.refresh().status, "indeterminate"); +}); diff --git a/tests/session-profile-append-controller.test.ts b/tests/session-profile-append-controller.test.ts new file mode 100644 index 000000000..1f119144c --- /dev/null +++ b/tests/session-profile-append-controller.test.ts @@ -0,0 +1,530 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + createSessionProfileAppendController, + type SessionProfileAppendOutcome, +} from "../lib/session-profile-append-controller.ts"; +import { normalizeModelConfig } from "../lib/model-routing-authority.ts"; +import { SESSION_PROFILE_CUSTOM_TYPE as customType } from "../lib/session-profile-persistence.ts"; + +function fixture() { + let id = "session", + file: string | undefined = "session.jsonl", + rows: any[] = [], + text: string | undefined; + let mode = "normal", + serial = 0; + const source = { + getSessionId: () => id, + getSessionFile: () => file, + getBranch: () => rows, + }; + const readFile = () => { + if (text === undefined) + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + if (text === "unreadable") + throw Object.assign(new Error("denied"), { code: "EACCES" }); + return text; + }; + const save = () => { + text = [ + JSON.stringify({ type: "session", id }), + ...rows.map((row) => JSON.stringify(row)), + ].join("\n"); + }; + const append = (type: string, data: unknown) => { + if (mode === "throw-no-advance") throw new Error("append"); + rows.push({ + type: "custom", + customType: type, + data, + id: `entry-${++serial}`, + parentId: rows.at(-1)?.id ?? null, + timestamp: "now", + }); + if (mode === "throw") throw new Error("append"); + if (mode === "change") id = "other"; + if (mode === "disk" || mode === "throw-after-save") save(); + if (mode === "throw-after-save") throw new Error("append"); + }; + const controller = createSessionProfileAppendController(source, append, { + readFile, + }); + return { + source, + controller, + save, + append, + readFile, + get rows() { + return rows; + }, + set rows(value) { + rows = value; + }, + get text() { + return text; + }, + set text(value) { + text = value; + }, + set mode(value) { + mode = value; + }, + set id(value) { + id = value; + }, + set file(value) { + file = value; + }, + }; +} +function bound(result: SessionProfileAppendOutcome, name: string) { + const state = result.state; + assert.equal(state.status, "bound"); + if (state.status !== "bound") throw new Error("Expected bound authority"); + assert.equal(state.binding.name, name); + return state.binding; +} + +for (const operation of ["bind", "clear"] as const) { + test(`explicit preflush ${operation} is honest, refresh upgrades only after disk flush`, () => { + const f = fixture(); + const result = + operation === "bind" + ? f.controller.bind("chosen", {}) + : f.controller.clear(); + assert.equal(result.status, "not-persisted"); + assert.equal(result.state.status, operation === "bind" ? "bound" : "cleared"); + assert.equal(f.controller.refresh().status, "not-persisted"); + f.save(); + assert.equal(f.controller.refresh().status, "persisted"); + }); + test(`failed ${operation} advances ghost but preserves corroborated disk selection`, () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + f.mode = "throw"; + const result = + operation === "bind" + ? f.controller.bind("failed", {}) + : f.controller.clear(); + assert.equal(result.status, "append-failed-preserved"); + bound(result, "good"); + f.save(); + bound(f.controller.refresh(), "good"); + f.rows.push({ + type: "custom", + customType: "ordinary", + data: {}, + id: "ordinary", + parentId: f.rows.at(-1).id, + timestamp: "now", + }); + f.save(); + bound(f.controller.refresh(), "good"); + const rows = f.rows; + f.rows = rows.slice(0, 1); + f.controller.refresh(); + f.rows = rows; + bound(f.controller.refresh(), "good"); + f.mode = "disk"; + const recovery = + operation === "bind" + ? f.controller.bind("recovered", {}) + : f.controller.clear(); + assert.equal(recovery.status, "persisted"); + assert.equal( + recovery.state.status, + operation === "bind" ? "bound" : "cleared", + ); + }); +} +test("restoration does not activate an unwritten public branch", () => { + const f = fixture(); + f.append(customType, { + kind: "bind", + origin: "user", + name: "unwritten", + modelProfiles: {}, + }); + assert.equal(f.controller.refresh().status, "indeterminate"); +}); +test("real normalized routes, user schema and detached orchestrator are preserved", () => { + const f = fixture(); + f.mode = "disk"; + const models = normalizeModelConfig({ + worker: "offline/model", + reviewer: { effort: "high" }, + orchestrator: "offline/controller", + })!; + const result = f.controller.bind("normalized", models); + assert.equal(result.status, "persisted"); + assert.deepEqual(f.rows.at(-1).data, { + kind: "bind", + origin: "user", + name: "normalized", + modelProfiles: { + worker: { model: "offline/model" }, + reviewer: { thinking: "high" }, + orchestrator: { model: "offline/controller" }, + }, + }); + bound(result, "normalized").modelProfiles.worker.model = "mutated"; + assert.equal( + bound(f.controller.refresh(), "normalized").modelProfiles.worker.model, + "offline/model", + ); +}); +for (const fault of [ + "missing", + "corrupt", + "unreadable", + "mismatch", + "wrong-session", +]) { + test(`successful append cannot adopt against established ${fault} disk`, () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("old", {}); + f.mode = "normal"; + if (fault === "missing") f.text = undefined; + if (fault === "corrupt") f.text = "{"; + if (fault === "unreadable") f.text = "unreadable"; + if (fault === "wrong-session") + f.text = JSON.stringify({ type: "session", id: "wrong" }); + const result = f.controller.bind("new", {}); + assert.equal(result.status, "indeterminate"); + assert.equal(f.controller.refresh().status, "indeterminate"); + }); +} +for (const status of ["bound", "cleared", "invalid", "unsupported"] as const) { + test(`no-advance exception retains only corroborated ${status} authority`, () => { + const f = fixture(); + f.mode = "disk"; + if (status === "bound") f.controller.bind("old", {}); + else + f.append( + status === "unsupported" ? "gentle-pi.session-profile/v2" : customType, + status === "cleared" ? { kind: "clear" } : {}, + ); + f.mode = "throw-no-advance"; + const result = f.controller.bind("failed", {}); + assert.equal(result.status, "append-failed-preserved"); + assert.equal(result.state.status, status); + f.text = "unreadable"; + assert.equal(f.controller.clear().status, "indeterminate"); + }); +} +for (const previous of ["cleared", "invalid", "unsupported"] as const) { + for (const failure of ["bind", "clear"] as const) { + test(`advanced failed ${failure} preserves terminal prior ${previous}, even after ghost reaches disk`, () => { + const f = fixture(); + f.mode = "disk"; + f.append( + previous === "unsupported" ? "gentle-pi.session-profile/v2" : customType, + previous === "cleared" ? { kind: "clear" } : {}, + ); + f.mode = "throw"; + const result = + failure === "bind" ? f.controller.bind("ghost", {}) : f.controller.clear(); + assert.equal(result.status, "append-failed-preserved"); + assert.equal(result.state.status, previous); + f.save(); + assert.equal(f.controller.refresh().state.status, previous); + f.mode = "disk"; + assert.equal(f.controller.clear().status, "persisted"); + assert.equal(f.controller.refresh().state.status, "cleared"); + }); + } +} +test("no disk on no-advance failure cannot manufacture previous authority", () => { + const f = fixture(); + f.mode = "throw-no-advance"; + assert.equal(f.controller.bind("failed", {}).status, "indeterminate"); + assert.equal(f.rows.length, 0); + assert.equal(f.controller.refresh().status, "indeterminate"); +}); +test("throw after exact disk write is still never success and quarantines failed record", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + f.mode = "throw-after-save"; + const result = f.controller.clear(); + assert.equal(result.status, "append-failed-preserved"); + bound(result, "good"); + bound(f.controller.refresh(), "good"); +}); +test("changed append scope fails closed without carrying authority across sessions", () => { + const f = fixture(); + f.mode = "change"; + assert.equal(f.controller.bind("new", {}).status, "indeterminate"); + assert.equal(f.controller.refresh().status, "indeterminate"); +}); +test("pending selection revoked by branch switch cannot return from older memory", () => { + const f = fixture(); + f.controller.bind("pending", {}); + const rows = f.rows; + f.rows = []; + assert.equal(f.controller.refresh().status, "indeterminate"); + f.rows = rows; + assert.equal(f.controller.refresh().status, "indeterminate"); +}); +for (const terminal of [customType, "gentle-pi.session-profile/v2"]) { + test(`new terminal ${terminal} never resurrects pending or disk older binding`, () => { + const f = fixture(); + f.controller.bind("pending", {}); + f.append(terminal, {}); + f.save(); + assert.equal( + f.controller.refresh().state.status, + terminal === customType ? "invalid" : "unsupported", + ); + f.rows = f.rows.slice(0, 1); + f.text = undefined; + assert.equal(f.controller.refresh().status, "indeterminate"); + }); +} +test("quarantine survives repeated exceptions and does not cross session or file", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.clear(); + f.mode = "throw"; + f.controller.bind("failed-one", {}); + f.controller.bind("failed-two", {}); + f.save(); + assert.equal(f.controller.refresh().state.status, "cleared"); + f.id = "replacement"; + f.save(); + bound(f.controller.refresh(), "failed-two"); + f.id = "session"; + f.file = "replacement.jsonl"; + f.save(); + bound(f.controller.refresh(), "failed-two"); + f.file = "session.jsonl"; + assert.equal(f.controller.refresh().state.status, "cleared"); +}); +test("separate controller cannot restore another controller's volatile selection", () => { + const f = fixture(); + f.controller.bind("pending", {}); + const other = createSessionProfileAppendController(f.source, f.append, { + readFile: () => { + throw Object.assign(new Error(), { code: "ENOENT" }); + }, + }); + assert.equal(other.refresh().status, "indeterminate"); +}); + +test("pending scope is revoked across session/file switches without transferring volatile state", () => { + const f = fixture(); + f.controller.bind("pending", {}); + f.id = "other"; + assert.equal(f.controller.refresh().status, "indeterminate"); + f.id = "session"; + assert.equal(f.controller.refresh().status, "indeterminate"); + f.controller.clear(); + f.file = undefined; + assert.equal(f.controller.refresh().status, "indeterminate"); + f.file = "session.jsonl"; + assert.equal(f.controller.refresh().status, "indeterminate"); +}); +test("undefined file permits only a freshly explicit setup-only selection", () => { + const f = fixture(); + f.file = undefined; + assert.equal(f.controller.clear().status, "not-persisted"); + assert.equal(f.controller.refresh().status, "not-persisted"); +}); +test("missing-file evidence permits only a NEW explicit selection, not unwritten restoration or first-flush proof", () => { + const f = fixture(); + f.append(customType, { kind: "clear" }); + assert.equal(f.controller.refresh().status, "indeterminate"); + const result = f.controller.bind("new", {}); + assert.equal(result.status, "not-persisted"); + bound(result, "new"); + assert.equal(f.controller.refresh().status, "not-persisted"); +}); +test("conversation with missing disk cannot be relabelled as preflush", () => { + const f = fixture(); + f.rows.push({ + type: "message", + id: "message", + parentId: null, + timestamp: "now", + message: { role: "user" }, + }); + assert.equal(f.controller.clear().status, "indeterminate"); +}); +for (const mutation of ["file", "branch", "payload", "no-append"] as const) { + test(`successful ${mutation} mutation fails closed and cannot activate by refresh`, () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + const candidate = createSessionProfileAppendController( + f.source, + (type, data) => { + if (mutation === "no-append") return; + f.append(type, mutation === "payload" ? { kind: "clear" } : data); + if (mutation === "file") f.file = "changed.jsonl"; + if (mutation === "branch") { + f.rows = f.rows.slice(-1); + f.save(); + } + }, + { readFile: () => f.text! }, + ); + assert.equal(candidate.bind("requested", {}).status, "indeterminate"); + assert.equal(candidate.refresh().status, "indeterminate"); + }); +} +test("exact selected disk content mismatch and changed source during read are indeterminate", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + const mismatch = createSessionProfileAppendController( + f.source, + (type, data) => { + f.append(type, data); + const lines = f.text!.split("\n"); + const row = JSON.parse(lines.at(-1)!); + row.data.name = "mismatched"; + lines[lines.length - 1] = JSON.stringify(row); + f.text = lines.join("\n"); + }, + { readFile: () => f.text! }, + ); + assert.equal(mismatch.bind("requested", {}).status, "indeterminate"); + const changing = createSessionProfileAppendController(f.source, f.append, { + readFile: () => { + f.id = "changed"; + return f.text!; + }, + }); + assert.equal(changing.refresh().status, "indeterminate"); +}); +test("no-advance failed clear preserves a prior tombstone and never reports success", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.clear(); + f.mode = "throw-no-advance"; + const failed = f.controller.clear(); + assert.equal(failed.status, "append-failed-preserved"); + assert.equal(failed.state.status, "cleared"); +}); +test("JSON omission and toJSON semantics match observable disk without leaking references", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("json", {}); + const row = f.rows[0]; + row.extra = undefined; + row.toJSON = function () { + return { ...this, extra: undefined }; + }; + f.save(); + bound(f.controller.refresh(), "json"); +}); +for (const failure of ["cycle", "bigint", "getter", "toJSON"] as const) { + test(`unserializable ${failure} source cannot manufacture authority`, () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + const row = f.rows[0]; + if (failure === "cycle") row.extra = row; + if (failure === "bigint") row.extra = 1n; + if (failure === "getter") + Object.defineProperty(row, "extra", { + enumerable: true, + get() { + throw new Error("private detail"); + }, + }); + if (failure === "toJSON") + row.toJSON = () => { + throw new Error("private detail"); + }; + assert.equal(f.controller.refresh().status, "indeterminate"); + assert.equal(f.controller.clear().status, "indeterminate"); + }); +} +test("failed snapshot still quarantines observable IDs, explicit recovery cannot resurrect ghost", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + let fail = false; + const candidate = createSessionProfileAppendController( + f.source, + (type, data) => { + f.append(type, data); + if (fail) { + f.rows.at(-1).extra = 1n; + throw new Error("append"); + } + }, + { readFile: () => f.text! }, + ); + assert.equal(candidate.bind("trusted", {}).status, "persisted"); + fail = true; + assert.equal(candidate.bind("ghost", {}).status, "indeterminate"); + delete f.rows.at(-1).extra; + f.save(); + assert.equal(candidate.refresh().status, "indeterminate"); + const ghostBranch = f.rows.slice(); + fail = false; + assert.equal(candidate.clear().status, "persisted"); + f.rows = ghostBranch; + assert.equal(candidate.refresh().status, "indeterminate"); +}); +// Injected structural-source fault, NOT ordinary Pi ID reuse or EISDIR. +for (const changed of ["data", "timestamp", "parentId", "extra"] as const) { + test(`whole-record certificate rejects same-ID ${changed} mutation after throw, recovery and branch return`, () => { + const f = fixture(); + f.mode = "disk"; + let fail = false; + const candidate = createSessionProfileAppendController( + f.source, + (type, data) => { + if (!fail) return f.append(type, data); + const row = f.rows[0]; + if (changed === "data") row.data.name = "FAILED"; + if (changed === "timestamp") row.timestamp = "changed"; + if (changed === "parentId") row.parentId = "injected-missing-parent"; + if (changed === "extra") row.extra = { injected: "FAILED" }; + f.save(); + throw new Error("injected mutation during failed append"); + }, + { readFile: () => f.text! }, + ); + assert.equal(candidate.bind("trusted", {}).status, "persisted"); + const trusted = JSON.parse(JSON.stringify(f.rows)); + fail = true; + assert.equal(candidate.bind("failed", {}).status, "indeterminate"); + const changedBranch = f.rows.slice(); + fail = false; + assert.equal(candidate.clear().status, "persisted"); + assert.equal(candidate.refresh().state.status, "cleared"); + f.rows = changedBranch; + assert.equal(candidate.refresh().status, "indeterminate"); + // Restoring the exact certified fixture content is not history repair. + f.rows = trusted; + f.save(); + bound(candidate.refresh(), "trusted"); + }); +} +test("unobservable newer branch revokes pending selection permanently", () => { + const f = fixture(); + assert.equal(f.controller.bind("pending", {}).status, "not-persisted"); + const original = f.rows.slice(); + f.append(customType, {}); + f.rows.at(-1).extra = 1n; + assert.equal(f.controller.refresh().status, "indeterminate"); + f.rows = original; + assert.equal(f.controller.refresh().status, "indeterminate"); +}); +test("invalid encoder input fails before append without corrupting existing authority", () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("good", {}); + const length = f.rows.length; + assert.throws(() => f.controller.bind("", {}), TypeError); + assert.equal(f.rows.length, length); + bound(f.controller.refresh(), "good"); +}); From 359d48fd8f636a450cb2c09f03b29494df9215d9 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Thu, 8 Oct 2026 15:51:54 -0600 Subject: [PATCH 06/15] refactor(profiles): share session profile family and metadata predicates Address CodeRabbit review on #1948: the controller, disk reader and persistence codec each kept their own copy of the session-profile family prefix check, and the controller re-implemented the reader's own-key candidate metadata check. Export isSessionProfileFamilyEntry from the codec and hasSessionProfileCandidateMetadata from the reader, and use them everywhere. --- lib/session-profile-append-controller.ts | 35 ++++++++---------------- lib/session-profile-disk-reader.ts | 27 ++++++++---------- lib/session-profile-persistence.ts | 20 ++++++++++---- 3 files changed, 36 insertions(+), 46 deletions(-) diff --git a/lib/session-profile-append-controller.ts b/lib/session-profile-append-controller.ts index 61282df1c..2ebef13a1 100644 --- a/lib/session-profile-append-controller.ts +++ b/lib/session-profile-append-controller.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { + hasSessionProfileCandidateMetadata, readSessionProfileDisk, type DiskProfileResult, type SessionProfileSource, @@ -9,6 +10,7 @@ import { import { createSessionProfileBind, createSessionProfileClear, + isSessionProfileFamilyEntry, readSessionProfileEntry, SESSION_PROFILE_CUSTOM_TYPE, type SessionProfileBindPayload, @@ -61,13 +63,6 @@ function detached(value: T): T { throw new TypeError("Unserializable controller snapshot"); } } -function family(entry: Entry): boolean { - return ( - entry.type === "custom" && - typeof entry.customType === "string" && - entry.customType.startsWith("gentle-pi.session-profile/") - ); -} function unavailable(reason: string): SessionProfileAppendOutcome { return { status: "indeterminate", state: { status: "indeterminate", reason } }; } @@ -109,19 +104,11 @@ export function createSessionProfileAppendController( // Match the reader's own-field requirements before JSON can hide inherited // metadata or an entry's toJSON can manufacture otherwise missing fields. for (const value of raw) { - if (value && typeof value === "object" && family(value as Entry)) { - const entry = value as Entry; - if ( - !["type", "id", "parentId", "timestamp", "customType"].every((key) => - Object.hasOwn(entry, key), - ) || - typeof entry.timestamp !== "string" || - !entry.timestamp || - (entry.parentId !== null && - (typeof entry.parentId !== "string" || !entry.parentId)) - ) - throw new Error("profile-metadata"); - } + if ( + isSessionProfileFamilyEntry(value) && + !hasSessionProfileCandidateMetadata(value) + ) + throw new Error("profile-metadata"); } const branch = detached(raw) as Entry[]; if ( @@ -196,13 +183,13 @@ export function createSessionProfileAppendController( }; } function selected(c: Capture, s: Scope): Entry | undefined { - return c.branch.findLast((entry) => family(entry) && !s.failed.has(entry.id)); + return c.branch.findLast((entry) => isSessionProfileFamilyEntry(entry) && !s.failed.has(entry.id)); } function uncertainSelection(c: Capture, s: Scope): boolean { // Under unknown branch movement, even the raw newest family entry must be // a separately corroborated recovery. Exclusion cannot revive an older // certified selection when the newest entry's provenance is ambiguous. - const latest = c.branch.findLast(family); + const latest = c.branch.findLast(isSessionProfileFamilyEntry); return ( s.uncertain && (!latest || !isDeepStrictEqual(s.certified.get(latest.id), latest)) @@ -282,7 +269,7 @@ export function createSessionProfileAppendController( if (value && typeof value === "object") { const entry = value as Entry; if ( - family(entry) && + isSessionProfileFamilyEntry(entry) && typeof entry.id === "string" && entry.id && !old.has(entry.id) @@ -309,7 +296,7 @@ export function createSessionProfileAppendController( const oldIds = new Set(before.branch.map((entry) => entry.id)); const added = after.branch.filter((entry) => !oldIds.has(entry.id)); if (threw) { - for (const entry of added) if (family(entry)) s.failed.add(entry.id); + for (const entry of added) if (isSessionProfileFamilyEntry(entry)) s.failed.add(entry.id); if (!prefix(before.branch, after.branch)) { s.ambiguous = true; s.uncertain = true; diff --git a/lib/session-profile-disk-reader.ts b/lib/session-profile-disk-reader.ts index 04af43ddb..ba6ead4fc 100644 --- a/lib/session-profile-disk-reader.ts +++ b/lib/session-profile-disk-reader.ts @@ -2,16 +2,11 @@ import { readFileSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; import { + isSessionProfileFamilyEntry, readSessionProfileEntry, - SESSION_PROFILE_CUSTOM_TYPE, type SessionProfileReadResult, } from "./session-profile-persistence.ts"; -const profileFamily = SESSION_PROFILE_CUSTOM_TYPE.slice( - 0, - SESSION_PROFILE_CUSTOM_TYPE.lastIndexOf("/") + 1, -); - export interface SessionProfileSource { getSessionId(): string; getSessionFile(): string | undefined; @@ -37,6 +32,12 @@ export interface DiskProfileOptions { function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Own required metadata of a profile-family candidate, before serialization. */ +export function hasSessionProfileCandidateMetadata(value: unknown): boolean { + return ( + record(value) && metadata(value) && Object.hasOwn(value, "customType") + ); +} function metadata(value: Record): boolean { return ( ["type", "id", "parentId", "timestamp"].every((key) => @@ -87,11 +88,8 @@ export function readSessionProfileDisk( // missing/inherited fields. Ordinary branch entries have no profile meaning. for (const raw of rawBranch) { if ( - record(raw) && - raw.type === "custom" && - typeof raw.customType === "string" && - raw.customType.startsWith(profileFamily) && - (!metadata(raw) || !Object.hasOwn(raw, "customType")) + isSessionProfileFamilyEntry(raw) && + !hasSessionProfileCandidateMetadata(raw) ) return unavailable("invalid-candidate-metadata"); } @@ -101,11 +99,8 @@ export function readSessionProfileDisk( for (let index = branch.length - 1; index >= 0; index--) { const candidate = branch[index]; if ( - record(candidate) && - candidate.type === "custom" && - typeof candidate.customType === "string" && - candidate.customType.startsWith(profileFamily) && - !failed.has(candidate.id as string) + isSessionProfileFamilyEntry(candidate) && + !failed.has((candidate as { id?: unknown }).id as string) ) { entryIndex = index; break; diff --git a/lib/session-profile-persistence.ts b/lib/session-profile-persistence.ts index 4585d6ea1..9e916b4fe 100644 --- a/lib/session-profile-persistence.ts +++ b/lib/session-profile-persistence.ts @@ -13,6 +13,19 @@ import { export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1"; const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/"; +/** True for any custom entry in the session-profile family, known version or not. */ +export function isSessionProfileFamilyEntry(entry: unknown): boolean { + return ( + typeof entry === "object" && + entry !== null && + (entry as { type?: unknown }).type === "custom" && + typeof (entry as { customType?: unknown }).customType === "string" && + (entry as { customType: string }).customType.startsWith( + SESSION_PROFILE_FAMILY, + ) + ); +} + export type SessionProfileOrigin = "user" | "local" | "repo" | "global"; export interface SessionProfileBindPayload { @@ -148,12 +161,7 @@ export function createSessionProfileClear(): SessionProfileClearPayload { export function readSessionProfileEntry( entry: SessionProfileEntry, ): SessionProfileReadResult { - if ( - entry.type !== "custom" || - typeof entry.customType !== "string" || - !entry.customType.startsWith(SESSION_PROFILE_FAMILY) - ) - return { status: "absent" }; + if (!isSessionProfileFamilyEntry(entry)) return { status: "absent" }; if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE) return { status: "unsupported" }; const data = entry.data; From 0a681d91ff197705fc7b7481584a8ce895d4b1da Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 11:44:38 -0600 Subject: [PATCH 07/15] fix(profiles): keep uncorroborated appends untrusted after disk recovery An append reported as append-not-corroborated was adopted as persisted once the disk recovered, silently changing routing after Enter reported failure. The controller now marks the scope uncertain so only a fresh explicit, corroborated bind or clear recovers. Refs #1064 --- lib/session-profile-append-controller.ts | 4 +++ .../session-profile-append-controller.test.ts | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/session-profile-append-controller.ts b/lib/session-profile-append-controller.ts index 2ebef13a1..31574edaf 100644 --- a/lib/session-profile-append-controller.ts +++ b/lib/session-profile-append-controller.ts @@ -351,8 +351,12 @@ export function createSessionProfileAppendController( state: readSessionProfileEntry(entry), }); } + // Reported as not adopted: a later disk recovery must not adopt it + // silently. Only a fresh explicit, corroborated bind or clear recovers. + s.uncertain = true; return unavailable("append-not-corroborated"); } catch { + s.uncertain = true; return unavailable("append-not-corroborated"); } } diff --git a/tests/session-profile-append-controller.test.ts b/tests/session-profile-append-controller.test.ts index 1f119144c..82456bb06 100644 --- a/tests/session-profile-append-controller.test.ts +++ b/tests/session-profile-append-controller.test.ts @@ -198,6 +198,32 @@ for (const fault of [ assert.equal(f.controller.refresh().status, "indeterminate"); }); } +for (const fault of [ + "missing", + "corrupt", + "unreadable", + "mismatch", + "wrong-session", +]) { + test(`uncorroborated append stays untrusted after ${fault} disk recovers`, () => { + const f = fixture(); + f.mode = "disk"; + f.controller.bind("old", {}); + f.mode = "normal"; + if (fault === "missing") f.text = undefined; + if (fault === "corrupt") f.text = "{"; + if (fault === "unreadable") f.text = "unreadable"; + if (fault === "wrong-session") + f.text = JSON.stringify({ type: "session", id: "wrong" }); + assert.equal(f.controller.bind("new", {}).status, "indeterminate"); + f.save(); + const later = f.controller.refresh(); + assert.equal(later.status, "indeterminate", "a reported failure is never adopted later"); + assert.equal(later.state.status, "indeterminate"); + f.mode = "disk"; + bound(f.controller.bind("again", {}), "again"); + }); +} for (const status of ["bound", "cleared", "invalid", "unsupported"] as const) { test(`no-advance exception retains only corroborated ${status} authority`, () => { const f = fixture(); From b6d3e8a9be3773154a13ae29fb8cdc0d902f1af0 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 12:59:19 -0600 Subject: [PATCH 08/15] test(profiles): cover a throwing post-append stability recheck Refs #1064 --- .../session-profile-append-controller.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/session-profile-append-controller.test.ts b/tests/session-profile-append-controller.test.ts index 82456bb06..233f2247b 100644 --- a/tests/session-profile-append-controller.test.ts +++ b/tests/session-profile-append-controller.test.ts @@ -554,3 +554,62 @@ test("invalid encoder input fails before append without corrupting existing auth assert.equal(f.rows.length, length); bound(f.controller.refresh(), "good"); }); + +// A stability recheck that throws after a successful append takes the catch +// path; the record must stay untrusted even though the disk is sound. The +// fourth branch read after the append is that recheck. +test("a throwing post-append stability recheck is never adopted later", () => { + const failingRead = 4; + let rows: any[] = [], + text: string | undefined, + serial = 0, + armed = false, + reads = 0; + const save = () => { + text = [ + JSON.stringify({ type: "session", id: "session" }), + ...rows.map((row) => JSON.stringify(row)), + ].join("\n"); + }; + const source = { + getSessionId: () => "session", + getSessionFile: () => "session.jsonl", + getBranch: () => { + if (armed && ++reads === failingRead) throw new Error("branch"); + return rows; + }, + }; + const controller = createSessionProfileAppendController( + source, + (type, data) => { + rows.push({ + type: "custom", + customType: type, + data, + id: `entry-${++serial}`, + parentId: rows.at(-1)?.id ?? null, + timestamp: "now", + }); + save(); + armed = true; + }, + { + readFile: () => { + if (text === undefined) + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + return text; + }, + }, + ); + const result = controller.bind("new", {}); + armed = false; + assert.equal(result.status, "indeterminate"); + assert.equal( + (result.state as { reason?: string }).reason, + "append-not-corroborated", + ); + const later = controller.refresh(); + assert.equal(later.status, "indeterminate", "a reported failure is never adopted later"); + armed = false; + bound(controller.bind("again", {}), "again"); +}); From cf04576619372007bc9e5e8cc55c4f90263c69c1 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Thu, 8 Oct 2026 13:28:19 -0600 Subject: [PATCH 09/15] 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 31574edaf..b07d31302 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; }; export type SessionProfileAppendOutcome = { @@ -47,6 +50,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, @@ -55,6 +75,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)); @@ -80,7 +153,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( @@ -88,13 +162,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))) @@ -139,6 +223,7 @@ export function createSessionProfileAppendController( ambiguous: false, uncertain: false, certified: new Map(), + untrusted: new Set(), }; files.set(c.file, state); } @@ -158,6 +243,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; @@ -191,8 +277,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 { @@ -246,6 +333,7 @@ export function createSessionProfileAppendController( } let threw = false; try { + if (!appendEntry) return unavailable("detached-source"); appendEntry(SESSION_PROFILE_CUSTOM_TYPE, detached(payload)); } catch { threw = true; @@ -360,9 +448,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 94f4a1321840ecadf6f4843fc3543e0f04e0b5ab Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 10:23:21 -0600 Subject: [PATCH 10/15] feat(profiles): add session profile lifecycle integration adapter Refs #1064 --- lib/session-profile-integration.ts | 285 +++++++++ tests/session-profile-integration.test.ts | 693 ++++++++++++++++++++++ 2 files changed, 978 insertions(+) create mode 100644 lib/session-profile-integration.ts create mode 100644 tests/session-profile-integration.test.ts 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 921ef2ec488765d55ce7a5be3c3e4da56f685993 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 11:19:18 -0600 Subject: [PATCH 11/15] 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); +}); From ec0757b13b4e94b02f42cfa3a9eae0abb4c5f830 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 10:34:15 -0600 Subject: [PATCH 12/15] feat(profiles): gate subagent launches on session profile authority Require corroborated session profile authority before subagent_run, subagent_continue and every launch stage. Test session fakes now expose getSessionFile like real session managers. Refs #1064 --- extensions/gentle-agents.ts | 10 +- lib/session-profile-authority.ts | 107 ++++++++++++ tests/gentle-agents.test.ts | 10 +- tests/session-profile-authority.test.ts | 206 ++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 lib/session-profile-authority.ts create mode 100644 tests/session-profile-authority.test.ts diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 7f2ad396e..d2b52c2c8 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -27,7 +27,8 @@ import { resolveVisualSettings } from "../lib/visual-customization-policy.ts"; import { createCompletionQueue } from "../lib/agents-completion-delivery.ts"; import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts"; import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts"; -import { readSessionProfileBinding, sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts"; +import { sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts"; +import { requireSessionProfileAuthority } from "../lib/session-profile-authority.ts"; import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts"; import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts"; import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts"; @@ -1542,7 +1543,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // the same session layer and can never disagree about it (#1558: a bound // session used to kill its own non-git writer mid-preparation because // admission still read only the pin/global layers). - const sessionBinding = readSessionProfileBinding(ctx.sessionManager.getSessionId()); + const sessionBinding = requireSessionProfileAuthority(ctx.sessionManager).binding; if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) { const root = safeBootstrapDirectory(originalCwd); if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root."); @@ -1559,6 +1560,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = admittedModel = `${catalogModel.provider}/${catalogModel.id}`; if (!current() || !await prepareBoundSessionRepository(originalManager, originalCwd, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root || resolveSessionWorktree(originalCwd, originalCwd)?.root !== root) throw new Error("Writer repository preparation was unavailable or its session/target changed."); } + requireSessionProfileAuthority(originalManager); const parentCwd = ctx.sessionManager.getCwd(); // An explicit target is validated before any queue or session-dir writes. const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd); @@ -1602,6 +1604,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // with the same wholesale-replacement contract as the pin. The binding is // resolved here, at task-request creation, so queued and running children // keep the routing frozen into their requests even if the session rebinds. + requireSessionProfileAuthority(originalManager); const config = withPinnedModelProfiles( loadAgentsConfig(roots(ctx)), sessionOrPinModelProfiles( @@ -1626,6 +1629,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = foreignGrants.assertCurrent(ctx, identity); } if (signal?.aborted) throw new Error("Subagent launch aborted before queueing."); + requireSessionProfileAuthority(originalManager); mkdirSync(sessionDir, { recursive: true }); const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager; const parentSessionId = ctx.sessionManager.getSessionId() ?? ""; @@ -2037,6 +2041,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = }, }, async (params, ctx, signal) => { + requireSessionProfileAuthority(ctx.sessionManager); const work = Object.hasOwn(params, "work") ? decodeWorkDescriptor(params.work) : undefined; const manager = ctx.sessionManager, sessionId = manager.getSessionId(); if (typeof params.agent !== "string" || !params.agent.trim() || typeof params.task !== "string" || !params.task.trim() || (params.context !== undefined && typeof params.context !== "string") || (params.label !== undefined && typeof params.label !== "string")) throw new Error("Subagent dispatch requires a named agent, non-empty task and string context/label."); @@ -2122,6 +2127,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = "Resume a finished subagent task in its own session with a follow-up prompt.", { required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } }, async (params, ctx, signal) => { + requireSessionProfileAuthority(ctx.sessionManager); if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection")) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" }); const previous = await resolveTask(String(params.task_id)); if (!previous) return unknownTask(params.task_id, ctx); diff --git a/lib/session-profile-authority.ts b/lib/session-profile-authority.ts new file mode 100644 index 000000000..35f4caa2a --- /dev/null +++ b/lib/session-profile-authority.ts @@ -0,0 +1,107 @@ +// Current admission/publication bridge. Controller evidence stays in the +// integration owner; this module owns no quarantine, capability or cache. +import { existsSync } from "node:fs"; +import { readSessionProfileDisk } from "./session-profile-disk-reader.ts"; +import { + readCurrentSessionProfileOutcome, + type SessionProfileLifecycleManager, +} from "./session-profile-integration.ts"; +import type { SessionProfileAppendOutcome } from "./session-profile-append-controller.ts"; +import { + bindSessionProfile, + clearSessionProfileBinding, + readSessionProfileBinding, + type SessionProfileBinding, +} from "./session-profile-binding.ts"; + +export type SessionProfileAuthority = + | { available: true; binding?: SessionProfileBinding } + | { available: false }; + +/** Publish only the returned detached snapshot, including preserved disk state + * on append failure. Terminal invalid/future records never expose a fallback. + */ +export function publishSessionProfileOutcome( + manager: SessionProfileLifecycleManager, + outcome: SessionProfileAppendOutcome, +): SessionProfileAuthority { + const id = manager.getSessionId(); + const state = outcome.state; + if (state.status === "bound") { + bindSessionProfile(id, state.binding.name, state.binding.modelProfiles); + return { available: true, binding: readSessionProfileBinding(id) }; + } + clearSessionProfileBinding(id); + return state.status === "absent" || state.status === "cleared" + ? { available: true } + : { available: false }; +} + +function noProfile(manager: SessionProfileLifecycleManager): boolean { + const entries = manager.getBranch(); + return ( + Array.isArray(entries) && + !entries.some((entry) => { + if (!entry || typeof entry !== "object") return true; + const value = entry as { type?: unknown; customType?: unknown }; + return ( + value.type === "custom" && + typeof value.customType === "string" && + value.customType.startsWith("gentle-pi.session-profile/") + ); + }) + ); +} + +/** Load-order independent read. Without an owner, disk is read-only authority; + * with an owner, every request revalidates its existing negative evidence. + * An ordinary empty/preflush branch with no profile meaning retains its legacy + * routing. Missing disk never restores a profile found only in memory. + */ +export function readSessionProfileAuthority( + manager: SessionProfileLifecycleManager, +): SessionProfileAuthority { + try { + const id = manager.getSessionId(), + file = manager.getSessionFile?.(); + if (!id) return { available: false }; + const owned = readCurrentSessionProfileOutcome(manager); + const outcome = owned ?? { + status: "persisted" as const, + state: readSessionProfileDisk(manager), + }; + const empty = + outcome.state.status === "indeterminate" && + ["missing-source", "unreadable-file"].includes(outcome.state.reason) && + noProfile(manager) && + (file === undefined || !existsSync(file)); + if (manager.getSessionId() !== id || manager.getSessionFile?.() !== file) + return { available: false }; + if (empty) { + if (owned) clearSessionProfileBinding(id); + return { + available: true, + binding: owned ? undefined : readSessionProfileBinding(id), + }; + } + return publishSessionProfileOutcome(manager, outcome); + } catch { + try { + clearSessionProfileBinding(manager.getSessionId()); + } catch { + /* Unobservable manager. */ + } + return { available: false }; + } +} + +export function requireSessionProfileAuthority( + manager: SessionProfileLifecycleManager, +): SessionProfileAuthority & { available: true } { + const authority = readSessionProfileAuthority(manager); + if (!authority.available) + throw new Error( + "Session profile authority unavailable; affected launches are blocked until corroborated state or an explicit selection is available.", + ); + return authority; +} diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 1f7e19b2e..d6f107dae 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -201,7 +201,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t cwd, hasUI: true, mode: "tui", - sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] }, + sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [], getBranch: () => [], getSessionFile: () => undefined }, ui: { notify: (message: string) => dialogs.push(`notify:${message}`), custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay, options: unknown) => @@ -3982,8 +3982,8 @@ test("the card follows the active session: after /new the earlier session's task await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx); await tick(); assert.match(widget()![1], /◐ explore Long job/); - const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getBranch(): []; getEntries(): [] } }; - sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] }; + const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getBranch(): []; getEntries(): []; getSessionFile(): undefined } }; + sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getBranch: () => [], getEntries: () => [], getSessionFile: () => undefined }; await fire("session_start", ctx, { type: "session_start", reason: "new" }); assert.deepEqual(widget(), [], "the new session starts with an empty card"); assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/); @@ -3995,7 +3995,7 @@ test("the card follows the active session: after /new the earlier session's task assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator"); overlay.handleInput("\x1b"); await opened; - sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] }; + sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getBranch: () => [], getEntries: () => [], getSessionFile: () => undefined }; await fire("session_start", ctx, { type: "session_start", reason: "resume" }); assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again"); }); @@ -5800,7 +5800,7 @@ test("a subdirectory session cwd and workspace_root of the same worktree share o const env: NodeJS.ProcessEnv = {}; gentleAgents(h.pi, env, { ...runtime.deps, resolveWorktree, env, agentHome: profile }); const { ctx } = fakeContext(); - Object.assign(ctx, { cwd: sub, sessionManager: { getSessionId: () => "s1", getCwd: () => sub, getEntries: () => [], getBranch: () => [] } }); + Object.assign(ctx, { cwd: sub, sessionManager: { getSessionId: () => "s1", getCwd: () => sub, getEntries: () => [], getBranch: () => [], getSessionFile: () => undefined } }); await h.fire("session_start", ctx); const task = "Write it.\n\n## Allowed edit surfaces\nsrc/app.ts\n\n## Return\nReport"; try { diff --git a/tests/session-profile-authority.test.ts b/tests/session-profile-authority.test.ts new file mode 100644 index 000000000..786751e27 --- /dev/null +++ b/tests/session-profile-authority.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createEventBus, + createExtensionRuntime, + ExtensionRunner, + SessionManager, + type ExtensionAPI, + type ExtensionUIContext, +} from "@earendil-works/pi-coding-agent"; +import { loadExtensionFromFactory } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js"; +import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; +import gentleAgents, { + type SessionTransportFactory, +} from "../extensions/gentle-agents.ts"; +import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts"; +import { SESSION_PROFILE_CUSTOM_TYPE } from "../lib/session-profile-persistence.ts"; + +// Admission tests use actual factory loader/runner and manager. They do not +// claim runtime lifecycle or visual evidence: those are separate fixtures. +for (const order of ["agents-only", "agents-first", "owner-first"] as const) + for (const agent of ["explore", "worker"] as const) + test(`U3 unavailable authority blocks ${agent} admission before effects (${order})`, async (t) => { + const root = mkdtempSync(join(tmpdir(), "u3-admission-")), + home = join(root, "home"); + const definitions = join(home, ".pi", "agent", "agents"); + mkdirSync(definitions, { recursive: true }); + for (const name of ["explore", "worker"]) + writeFileSync( + join(definitions, `${name}.md`), + "---\ndescription: fixture\nmodel: openai/gpt-4o\ntools: [read]\n---\nOffline fixture", + ); + const manager = SessionManager.create(root, join(root, "sessions")); + manager.appendMessage({ role: "user", content: "fixture", timestamp: 1 }); + manager.appendCustomEntry("gentle-pi.session-profile/v99", { + kind: "future", + }); + const initialDisk = readFileSync(manager.getSessionFile()!, "utf8"); + const counts = { registry: 0, prepare: 0, child: 0, config: 0 }; + const transport: SessionTransportFactory = { + createRegistry: async () => ({ + list: async () => [], + listActivations: async () => [], + }), + createListener: (registry) => ({ + registry, + start: async () => {}, + close: async () => {}, + }), + createClient: () => ({ + close() {}, + sendNotification: async () => { + throw Error("no messaging in fixture"); + }, + }), + }; + const runtime = createExtensionRuntime(), + bus = createEventBus(); + const agentsFactory = (pi: ExtensionAPI) => + gentleAgents( + pi, + {}, + { + home, + agentHome: join(home, ".pi", "agent"), + env: { GENTLE_PI_CONFIG_HOME: join(root, "config") }, + sessionTransport: transport, + schedule: () => () => {}, + childExtensionPaths: [], + resolveWorktree: () => { + counts.registry++; + return undefined; + }, + spawn: () => { + counts.child++; + throw Error("fixture forbids actual children"); + }, + }, + ); + const ownerFactory = createGentleAiExtension({ + nativeReviewCli: null, + candidateViews: null, + processEnv: { GENTLE_PI_AGENTS_CHILD: "1" }, + }); + const factories = + order === "agents-only" + ? [agentsFactory] + : order === "agents-first" + ? [agentsFactory, ownerFactory] + : [ownerFactory, agentsFactory]; + const extensions = []; + for (const factory of factories) + extensions.push( + await loadExtensionFromFactory(factory, root, bus, runtime), + ); + const runner = new ExtensionRunner(extensions, runtime, root, manager, { + find: () => { + counts.config++; + return { provider: "openai", id: "gpt-4o" }; + }, + getAll: () => [], + } as never); + runner.setUIContext( + { + notify() {}, + setStatus() {}, + setWidget() {}, + } as unknown as ExtensionUIContext, + "tui", + ); + runner.bindCommandContext(); + await runner.emit({ type: "session_start", reason: "startup" }); + const unbind = bindSessionRepositoryPreparation( + manager, + root, + async () => { + counts.prepare++; + return false; + }, + () => true, + ); + t.after(async () => { + unbind(); + await runner.emit({ type: "session_shutdown", reason: "quit" }); + runner.invalidate(); + rmSync(root, { recursive: true, force: true }); + }); + counts.registry = 0; + counts.config = 0; + const entries = manager.getEntries().length; + const tool = runner + .getAllRegisteredTools() + .find(({ definition }) => definition.name === "subagent_run")!.definition; + await assert.rejects( + tool.execute( + "unavailable", + { + agent, + task: + agent === "worker" + ? "Implement\n## Allowed edit surfaces\nsrc/app.ts" + : "Map safely", + mode: "background", + } as never, + undefined, + undefined, + runner.createCommandContext() as never, + ), + /session profile authority unavailable/i, + ); + assert.deepEqual( + counts, + { registry: 0, prepare: 0, child: 0, config: 0 }, + "unavailable authority cannot silently fall through to shared routing", + ); + assert.equal( + manager.getEntries().length, + entries, + "no queue/publication entries", + ); + assert.equal( + readFileSync(manager.getSessionFile()!, "utf8"), + initialDisk, + "rejected data and disk remain unchanged", + ); + }); + +test("U3 independently evaluated binding readers share only process-local snapshots", async () => { + const { + bindSessionProfile, + readSessionProfileBinding, + clearSessionProfileBinding, + } = await import("../lib/session-profile-binding.ts"); + const moduleUrl = new URL( + "../lib/session-profile-binding.ts?u3-module-isolation", + import.meta.url, + ).href; + const copy = (await import( + moduleUrl + )) as typeof import("../lib/session-profile-binding.ts"); + const models = { worker: { model: "openai/gpt-4o" } }; + bindSessionProfile("u3-module", "team", models); + models.worker.model = "changed"; + assert.equal( + copy.readSessionProfileBinding("u3-module")?.modelProfiles.worker?.model, + "openai/gpt-4o", + ); + const snapshot = copy.readSessionProfileBinding("u3-module")!; + snapshot.modelProfiles.worker.model = "changed-reader"; + assert.equal( + readSessionProfileBinding("u3-module")?.modelProfiles.worker?.model, + "openai/gpt-4o", + ); + clearSessionProfileBinding("u3-module"); + assert.equal(copy.readSessionProfileBinding("u3-module"), undefined); + assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1"); +}); From 49862aeded5162bb813a97d80f727ee84676e611 Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 11:25:18 -0600 Subject: [PATCH 13/15] fix(profiles): decide profile-less branches before reading the session file Unrelated session-file damage no longer blocks launches on a branch with no profile record, and a binding left from another branch is dropped. Drop the back-to-back authority recheck before queueing. Refs #1064 --- extensions/gentle-agents.ts | 1 - lib/session-profile-authority.ts | 8 +++++++- tests/session-profile-authority.test.ts | 27 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index d2b52c2c8..a8286089f 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -1629,7 +1629,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = foreignGrants.assertCurrent(ctx, identity); } if (signal?.aborted) throw new Error("Subagent launch aborted before queueing."); - requireSessionProfileAuthority(originalManager); mkdirSync(sessionDir, { recursive: true }); const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager; const parentSessionId = ctx.sessionManager.getSessionId() ?? ""; diff --git a/lib/session-profile-authority.ts b/lib/session-profile-authority.ts index 35f4caa2a..b49840500 100644 --- a/lib/session-profile-authority.ts +++ b/lib/session-profile-authority.ts @@ -56,7 +56,9 @@ function noProfile(manager: SessionProfileLifecycleManager): boolean { /** Load-order independent read. Without an owner, disk is read-only authority; * with an owner, every request revalidates its existing negative evidence. * An ordinary empty/preflush branch with no profile meaning retains its legacy - * routing. Missing disk never restores a profile found only in memory. + * routing. Missing disk never restores a profile found only in memory. A saved + * profile-less branch is decided before any disk read, so unrelated session-file + * damage never blocks it, and a binding left from another branch is dropped. */ export function readSessionProfileAuthority( manager: SessionProfileLifecycleManager, @@ -65,6 +67,10 @@ export function readSessionProfileAuthority( const id = manager.getSessionId(), file = manager.getSessionFile?.(); if (!id) return { available: false }; + if (noProfile(manager) && file !== undefined && existsSync(file)) { + clearSessionProfileBinding(id); + return { available: true }; + } const owned = readCurrentSessionProfileOutcome(manager); const outcome = owned ?? { status: "persisted" as const, diff --git a/tests/session-profile-authority.test.ts b/tests/session-profile-authority.test.ts index 786751e27..aa055a88c 100644 --- a/tests/session-profile-authority.test.ts +++ b/tests/session-profile-authority.test.ts @@ -24,6 +24,8 @@ import gentleAgents, { } from "../extensions/gentle-agents.ts"; import { bindSessionRepositoryPreparation } from "../lib/bounded-writer-admission.ts"; import { SESSION_PROFILE_CUSTOM_TYPE } from "../lib/session-profile-persistence.ts"; +import { readSessionProfileAuthority } from "../lib/session-profile-authority.ts"; +import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; // Admission tests use actual factory loader/runner and manager. They do not // claim runtime lifecycle or visual evidence: those are separate fixtures. @@ -204,3 +206,28 @@ test("U3 independently evaluated binding readers share only process-local snapsh assert.equal(copy.readSessionProfileBinding("u3-module"), undefined); assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1"); }); + +// Profile-less branches never depend on parsing the rest of the session file. +function savedProfileLessSession(t: test.TestContext) { + const root = mkdtempSync(join(tmpdir(), "u3-profileless-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const manager = SessionManager.create(root, join(root, "sessions")); + manager.appendMessage({ role: "user", content: "fixture", timestamp: 1 }); + manager.appendMessage({ role: "assistant", content: [{ type: "text", text: "ok" }], api: "openai-completions", provider: "openai", model: "gpt-4o", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: 2 } as never); + const file = manager.getSessionFile()!; + assert.ok(readFileSync(file, "utf8").length > 0, "fixture session is flushed"); + return { manager, file }; +} + +test("unrelated session-file corruption does not block a profile-less branch", (t) => { + const { manager, file } = savedProfileLessSession(t); + writeFileSync(file, `${readFileSync(file, "utf8")}{not json\n`); + assert.deepEqual(readSessionProfileAuthority(manager), { available: true }); +}); + +test("a profile-less saved branch drops a binding left from another branch", (t) => { + const { manager } = savedProfileLessSession(t); + bindSessionProfile(manager.getSessionId(), "other-branch", { worker: { model: "openai/gpt-4o" } }); + assert.deepEqual(readSessionProfileAuthority(manager), { available: true }); + assert.equal(readSessionProfileBinding(manager.getSessionId()), undefined); +}); From 5e1c79ff562ab8ba26120dec34d2b12294b38a2c Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 10:43:25 -0600 Subject: [PATCH 14/15] feat(profiles): persist Enter session profile selection and wire lifecycle Enter appends the session profile record, publishes only the returned state, and switches the live orchestrator while that snapshot stays current. Session start, fork, tree and shutdown hooks drive the profile integration, and the profiles panel closes instead of reusing a ctx made stale by reload or session replacement. Refs #1064 --- docs/readme-reference.md | 13 +- extensions/gentle-ai.ts | 96 ++++- tests/gentle-ai.test.ts | 33 +- tests/session-profile-extension.test.ts | 538 ++++++++++++++++++++++++ 4 files changed, 654 insertions(+), 26 deletions(-) create mode 100644 tests/session-profile-extension.test.ts diff --git a/docs/readme-reference.md b/docs/readme-reference.md index b3449331f..41b63dc60 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -815,7 +815,7 @@ Profiles are named, switchable snapshots of the global agent-model routing from | Key | Action | | ------- | ---------------------------------------------------------------------- | -| `enter` | Apply the selected profile to this session only: switch its live orchestrator when defined and bind its subagent and reviewer routing snapshot. Shared defaults and pins are untouched. | +| `enter` | Apply the selected profile to this session only: append its session record, bind its subagent and reviewer routing snapshot, then switch its live orchestrator when defined. Shared defaults and pins are untouched. | | `a` | Explicitly set the selected profile as the global default and materialize routing. Inside a pinned repository it updates the clone-local pin instead and leaves the orchestrator unchanged; see **Per-repository pins** below. | | `c` | Create a new, empty profile. | | `s` | Snapshot the current routing into the selected profile (including the orchestrator currently set in `settings.json`); live routing is unchanged. | @@ -830,7 +830,16 @@ Profiles are named, switchable snapshots of the global agent-model routing from | `pgup`/`pgdn`, `ctrl+j`/`ctrl+k` | Scroll the detail pane by a page. | | `esc` | Close. | -Selecting a profile with Enter does not write `profiles.json`, `models.json`, Pi's global `settings.json`, agent frontmatter, `subagents.json`, or repository pins. Other open sessions keep their own bindings and live orchestrators. The session binding overrides pins and global routing as a complete snapshot, including reviewer routing; omitted roles do not fall back to another profile. The binding lasts in the current process (including `/reload`); resuming in another process requires selecting it again. +Selecting a profile with Enter does not write `profiles.json`, `models.json`, Pi's global `settings.json`, agent frontmatter, `subagents.json`, or repository pins. Other open sessions keep their own bindings and live orchestrators. The session binding overrides pins and global routing as a complete snapshot, including reviewer routing; omitted roles do not fall back to another profile. Already queued or running requests keep their frozen routing. + +| Outcome | What happens | +| --- | --- | +| Persisted | The selected record is corroborated on disk before routing is published. | +| Not yet persisted | Enter explicitly reports a preflush selection. It is usable now, but detach/reload does not restore it without later disk corroboration. | +| Append failure | No success or live-model effect. Keep only returned disk-corroborated state; otherwise block affected launches. | +| Invalid, future or unavailable authority | Do not fall through to shared routing; affected subagent launches are blocked. An explicit successful selection or fresh corroboration can restore availability. | + +Start, resume, reload, fork and tree navigation restore only the current corroborated branch state. Restoration never applies the profile's orchestrator. Fork capture is read-only until a confirmed transition; cancellation/rejection preserves current authority. Pending Enter live operations settle before fork-copy, tree movement or detach. Explicitly setting the global default with `a` writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. A profile is a complete snapshot: every discoverable agent it omits returns to inherit, so routing materialized by a previous profile, by `/gentle:models`, or by a migration never survives a switch silently. The reconciliation happens on the next subagent launch, and that launch still routes with the previous routing — expect one launch of lag after switching. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked. diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index ad28a9470..df5c164df 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -102,7 +102,9 @@ import { type ProfilePinSource, type ProfilePinStatus, } from "../lib/agent-profile-pin.ts"; -import { bindSessionProfile, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { clearSessionProfileBinding, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { createSessionProfileIntegration, readCurrentSessionProfileOutcome, runCurrentSessionProfileSelection, waitCurrentSessionProfileSelection } from "../lib/session-profile-integration.ts"; +import { publishSessionProfileOutcome, readSessionProfileAuthority } from "../lib/session-profile-authority.ts"; import { applyOrchestratorSettings, readOrchestratorSettings, @@ -4243,9 +4245,10 @@ async function runProfilesPanelAction( switch (result.type) { case "apply": { if (!hasOwnProfile(file.profiles, result.name)) return file; - // Enter applies a complete routing snapshot to this parent session and - // switches only its live orchestrator. No shared defaults, materialized - // stores, agent frontmatter, pins, or declarations are written. + // Enter persists a complete routing snapshot for this parent session, + // publishes only the returned state, then switches its live orchestrator. + // No shared defaults, materialized stores, agent frontmatter, pins, or + // declarations are written. const sessionId = ctx.sessionManager?.getSessionId?.(); if (typeof sessionId !== "string" || sessionId.length === 0) { ctx.ui.notify( @@ -4254,14 +4257,42 @@ async function runProfilesPanelAction( ); return file; } - const snapshot = normalizeModelConfig(file.profiles[result.name]) ?? {}; - bindSessionProfile(sessionId, result.name, snapshot); - const orchestrator = readProfileOrchestrator(snapshot); - const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator); - ctx.ui.notify( - `el Gentleman bound profile "${result.name}" to this session — shown as "${result.name} (session)". Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`, - "info", - ); + const selected = await runCurrentSessionProfileSelection(ctx.sessionManager, async (attachment, owned) => { + const outcome = attachment.bind(result.name, normalizeModelConfig(file.profiles[result.name]) ?? {}); + if (!owned()) return false; + const authority = publishSessionProfileOutcome(ctx.sessionManager, outcome); + if ((outcome.status !== "persisted" && outcome.status !== "not-persisted") || outcome.state.status !== "bound" || !authority.available) { + ctx.ui.notify( + `el Gentleman could not bind profile "${result.name}". ${authority.available ? "The last disk-corroborated state was preserved." : "Session profile authority is unavailable; affected launches are blocked."} No live model or thinking change was applied.`, + "warning", + ); + return false; + } + const snapshot = outcome.state.binding; + // The live switch applies only while this exact snapshot is still current. + const current = () => { + if (!owned()) return false; + const state = readCurrentSessionProfileOutcome(ctx.sessionManager)?.state; + return state?.status === "bound" && JSON.stringify(state.binding) === JSON.stringify(snapshot); + }; + const orchestrator = readProfileOrchestrator(snapshot.modelProfiles); + const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, { + setModel: (model) => current() ? live.setModel(model) : Promise.resolve(false), + setThinkingLevel: (level) => { if (current()) live.setThinkingLevel(level); }, + getThinkingLevel: () => live.getThinkingLevel(), + }, orchestrator); + if (current()) + ctx.ui.notify( + `el Gentleman bound profile "${snapshot.name}" to this session — shown as "${snapshot.name} (session)"; ${outcome.status === "persisted" ? "persisted in this session" : "not yet persisted; active only until detach or disk corroboration"}. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`, + "info", + ); + return true; + }); + if (selected === undefined) + ctx.ui.notify( + `el Gentleman cannot bind profile "${result.name}": the current session attachment is unavailable. No profile or live model change was applied.`, + "warning", + ); return file; } case "apply-global": { @@ -4869,11 +4900,16 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): while (result.type !== "close") { const report: ProfilesPanelReport = {}; file = await runProfilesPanelAction(ctx, live, path, file, result, report); + // A reload or session replacement while an action or read awaited makes + // this command ctx stale; the panel closes instead of touching it again. + if (!isCommandContextActive(ctx)) return; selectedName = report.selectedName ?? ("name" in result ? result.name : undefined); + const routing = await currentRoutingForPanel(); + if (!isCommandContextActive(ctx)) return; result = await showProfilesPanel( ctx, file, - await currentRoutingForPanel(), + routing, selectedName, saveSnapshot, report.status, @@ -4882,6 +4918,16 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): } } +/** Pi's runner throws on any access to a ctx made stale by reload or session replacement. */ +function isCommandContextActive(ctx: ExtensionContext): boolean { + try { + void ctx.sessionManager; + return true; + } catch { + return false; + } +} + async function handlePersonaCommand(ctx: ExtensionContext): Promise { const current = readPersonaMode(ctx.cwd); const selected = await ctx.ui.select( @@ -9447,12 +9493,28 @@ function createGentleAiExtensionForTesting( }; const reviewSidebar = createReviewSidebarPublisher(pi); - pi.on("session_tree", (_event, ctx) => reviewSidebar.reset(ctx)); + const sessionProfiles = createSessionProfileIntegration(); + pi.on("session_before_fork", async (event, ctx) => { + await waitCurrentSessionProfileSelection(ctx.sessionManager); + sessionProfiles.beforeFork(ctx.sessionManager, { entryId: event.entryId, position: event.position }); + }); + pi.on("session_before_tree", async (_event, ctx) => { + await waitCurrentSessionProfileSelection(ctx.sessionManager); + }); + pi.on("session_tree", (_event, ctx) => { + reviewSidebar.reset(ctx); + readSessionProfileAuthority(ctx.sessionManager); + }); let reminderSessionActive = true; let reminderEpoch = 0; let reminderManager: ExtensionContext["sessionManager"] | undefined; let unbindPreparation: (() => void) | undefined; - pi.on("session_shutdown", (event, context) => { + pi.on("session_shutdown", async (event, context) => { + await waitCurrentSessionProfileSelection(context.sessionManager); + if (sessionProfiles.isCurrent(context.sessionManager)) { + clearSessionProfileBinding(context.sessionManager.getSessionId()); + sessionProfiles.shutdown(context.sessionManager, event); + } yolo.reset(context); reviewSidebar.reset(); reminderSessionActive = false; @@ -9829,6 +9891,10 @@ function createGentleAiExtensionForTesting( })); pi.on("session_start", async (event, ctx) => { + const profileSessionId = ctx.sessionManager?.getSessionId?.(); + if (profileSessionId) clearSessionProfileBinding(profileSessionId); + sessionProfiles.start(ctx.sessionManager, (type, data) => pi.appendEntry(type, data), event); + readSessionProfileAuthority(ctx.sessionManager); yolo.reset(ctx); reviewSidebar.reset(ctx); elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi); diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 365cd479d..2e250fd4c 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; import { gzipSync } from "node:zlib"; -import { initTheme } from "@earendil-works/pi-coding-agent"; +import { initTheme, SessionManager } from "@earendil-works/pi-coding-agent"; import { validateToolArguments } from "@earendil-works/pi-ai"; import type { ExtensionAPI, @@ -17,6 +17,7 @@ import type { import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts"; import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts"; import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; +import { createSessionProfileIntegration } from "../lib/session-profile-integration.ts"; import type { AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts"; type LiveSession = Pick; import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts"; @@ -3605,6 +3606,14 @@ function readValidProfilesStore(path: string) { return result.file; } +/** A real persisted session started under the profile integration, so Enter can bind and persist. */ +function profileActionManager(root: string): SessionManager { + const manager = SessionManager.create(root, join(root, "session-fixture")); + manager.appendMessage({ role: "user", content: "isolated profile action", timestamp: 1 }); + createSessionProfileIntegration().start(manager, (type, data) => { manager.appendCustomEntry(type, data); }, { reason: "startup" }); + return manager; +} + test("Enter switches the live orchestrator, binds the profile, and writes nothing", async (t) => { const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t); writeSettings(); @@ -3615,11 +3624,12 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin }; resetSessionProfileBindingsForTesting(); const notifications: Array<{ message: string; severity: string }> = []; + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify(message: string, severity: string) { notifications.push({ message, severity }); } }, - sessionManager: { getSessionId: () => "session-panel" }, + sessionManager: manager, modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) }, } as unknown as ExtensionContext; const models: unknown[] = []; @@ -3633,15 +3643,17 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); assert.deepEqual(models, [{ provider: "openai", id: "beta" }]); assert.deepEqual(thinking, ["high"]); - const binding = readSessionProfileBinding("session-panel"); + const binding = readSessionProfileBinding(manager.getSessionId()); assert.equal(binding?.name, "team"); assert.equal(binding?.modelProfiles.worker?.model, "openai/alpha"); assert.equal(readFileSync(storePath, "utf8"), before.store, "the profiles store is untouched"); assert.equal(readFileSync(settingsPath, "utf8"), before.settings, "Pi settings are untouched"); assert.equal(existsSync(fixture.globalPath), false, "no global models.json is written"); assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written"); + assert.equal(manager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === "gentle-pi.session-profile/v1").length, 1, "only the session profile record is written"); const applied = notifications.at(-1)?.message ?? ""; assert.match(applied, /bound profile "team" to this session/); + assert.match(applied, /persisted in this session/); assert.match(applied, /shown as "team \(session\)"/); assert.match(applied, /This session now runs on openai\/beta · high/); assert.match(applied, /Subagents and reviewers use this session's routing snapshot/); @@ -3661,10 +3673,11 @@ test("Enter switches only the selecting session's orchestrator and leaves shared bindSessionProfile("other-session", "old", { worker: { model: "openai/old" } }); const switches: string[] = []; const thinking: ThinkingLevel[] = []; + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify() {} }, - sessionManager: { getSessionId: () => "selecting-session" }, + sessionManager: manager, modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) }, } as unknown as ExtensionContext; const live = { @@ -3675,7 +3688,7 @@ test("Enter switches only the selecting session's orchestrator and leaves shared await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" }); assert.deepEqual(switches, ["openai/alpha"]); assert.deepEqual(thinking, ["high"]); - assert.equal(readSessionProfileBinding("selecting-session")?.name, "team"); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.name, "team"); assert.equal(readSessionProfileBinding("other-session")?.modelProfiles.worker?.model, "openai/old"); assert.equal(readSessionProfileBinding("unbound-session"), undefined); assert.equal(readFileSync(storePath, "utf8"), beforeStore); @@ -3714,11 +3727,12 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca const notifications: string[] = []; let modelCalls = 0; let thinkingCalls = 0; + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify(message: string) { notifications.push(message); } }, - sessionManager: { getSessionId: () => "session-panel" }, + sessionManager: manager, modelRegistry: { find: () => ({ provider: "openai", id: "beta" }) }, } as unknown as ExtensionContext; const live = { @@ -3729,7 +3743,7 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" }, {}); assert.equal(modelCalls, rejected ? 1 : 0); assert.equal(thinkingCalls, 0); - assert.equal(readSessionProfileBinding("session-panel")?.modelProfiles.worker?.model, "openai/alpha"); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.modelProfiles.worker?.model, "openai/alpha"); assert.equal(readFileSync(storePath, "utf8"), storeBefore); assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore); assert.equal(existsSync(fixture.globalPath), false); @@ -3763,16 +3777,17 @@ test("Enter with a winning pin binds the session and never touches the pin layer const pinBefore = readFileSync(localPinPath, "utf8"); const storeBefore = readFileSync(storePath, "utf8"); resetSessionProfileBindingsForTesting(); + const manager = profileActionManager(fixture.root); const ctx = { cwd: fixture.root, hasUI: true, ui: { notify() {} }, - sessionManager: { getSessionId: () => "session-panel" }, + sessionManager: manager, } as unknown as ExtensionContext; const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } }; const file = readValidProfilesStore(storePath); await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {}); - assert.equal(readSessionProfileBinding("session-panel")?.name, "team"); + assert.equal(readSessionProfileBinding(manager.getSessionId())?.name, "team"); assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched"); assert.equal(readFileSync(storePath, "utf8"), storeBefore, "the store is untouched"); assert.equal(existsSync(fixture.globalPath), false); diff --git a/tests/session-profile-extension.test.ts b/tests/session-profile-extension.test.ts new file mode 100644 index 000000000..53d4a0602 --- /dev/null +++ b/tests/session-profile-extension.test.ts @@ -0,0 +1,538 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AgentSessionRuntime, + createAgentSession, + createEventBus, + createExtensionRuntime, + ModelRuntime, + SessionManager, + SettingsManager, + type AgentSession, + type ExtensionAPI, + type ExtensionUIContext, + type ResourceLoader, +} from "@earendil-works/pi-coding-agent"; +import { loadExtensionFromFactory } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js"; +import { getThemeByName } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js"; +import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; +import { readSessionProfileBinding } from "../lib/session-profile-binding.ts"; +import { SESSION_PROFILE_CUSTOM_TYPE } from "../lib/session-profile-persistence.ts"; + +// Actual SDK factory loader, AgentSession and replacement runtime. Only resource, +// auth and UI boundaries are fixture-owned. No prompts, provider streams or Git. +async function fixture(t: test.TestContext, memory = false, flushed = true) { + const root = mkdtempSync(join(tmpdir(), "u3-public-")); + const config = join(root, "config"), + agentDir = join(root, "agent"); + mkdirSync(config); + mkdirSync(agentDir); + const originalConfig = process.env.GENTLE_PI_CONFIG_HOME; + process.env.GENTLE_PI_CONFIG_HOME = config; + const profilesPath = join(config, "profiles.json"); + const profiles = { + team: { worker: { model: "openai/gpt-4o" } }, + empty: {}, + live: { orchestrator: { model: "openai/gpt-5.2", thinking: "high" } }, + }; + const saveProfiles = () => + writeFileSync( + profilesPath, + JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", + version: 1, + profiles, + }), + ); + saveProfiles(); + const modelRuntime = await ModelRuntime.create({ + authPath: join(agentDir, "auth.json"), + modelsPath: null, + refreshOnCreate: false, + allowModelNetwork: false, + }); + const baseModel = modelRuntime.getModel("openai", "gpt-4o"); + assert.ok(baseModel, "fixture model catalog prerequisite"); + assert.ok( + modelRuntime.getModel("openai", "gpt-5.2"), + "fixture live model catalog prerequisite", + ); + let authCalls = 0, + providerCalls = 0; + let auth: () => Promise = async () => ({ type: "api-key" }); + modelRuntime.hasConfiguredAuth = () => true; + modelRuntime.checkAuth = (async () => { + authCalls++; + return auth(); + }) as typeof modelRuntime.checkAuth; + modelRuntime.streamSimple = () => { + providerCalls++; + assert.fail("no provider requests"); + }; + const notices: Array<{ text: string; severity?: string }> = [], + errors: string[] = [], + events: string[] = []; + let selection: keyof typeof profiles | undefined; + let currentApi: ExtensionAPI | undefined; + let cancel = false; + const ui = { + theme: getThemeByName("dark")!, + getAllThemes: () => [{ name: "dark" }], + notify: (text: string, severity?: string) => notices.push({ text, severity }), + setStatus() {}, + setWidget() {}, + custom(factory: Parameters[0]) { + return new Promise((resolve) => { + void Promise.resolve( + factory( + { + terminal: { rows: 24 }, + requestRender() {}, + stop() {}, + start() {}, + } as never, + getThemeByName("dark")!, + {} as never, + resolve as never, + ), + ).then((panel) => { + if (selection === undefined) panel.handleInput?.("\x1b"); + else { + // The list keeps store order; arrows move it (j/k scroll the detail). + const names = Object.keys(profiles); + for (let i = 0; i < names.length; i++) panel.handleInput?.("\x1b[A"); + for (let i = 0; i < names.indexOf(selection); i++) + panel.handleInput?.("\x1b[B"); + selection = undefined; + panel.handleInput?.("\r"); + } + }); + }); + }, + } as unknown as ExtensionUIContext; + const make = async ( + manager: SessionManager, + startEvent: { + type: "session_start"; + reason: "startup" | "new" | "resume" | "fork"; + previousSessionFile?: string; + }, + ) => { + let extensionRuntime = createExtensionRuntime(); + let loaded: Awaited>[] = []; + const reload = async () => { + extensionRuntime = createExtensionRuntime(); + const bus = createEventBus(); + loaded = [ + await loadExtensionFromFactory( + (pi) => { + currentApi = pi; + createGentleAiExtension({ + nativeReviewCli: null, + candidateViews: null, + processEnv: { GENTLE_PI_AGENTS_CHILD: "1" }, + resolveTelemetryTriggerBinary: () => { + throw Error("disabled fixture telemetry"); + }, + })(pi); + }, + root, + bus, + extensionRuntime, + ), + await loadExtensionFromFactory( + (pi) => { + pi.on("session_before_fork", () => + cancel ? { cancel: true } : undefined, + ); + pi.on("session_before_tree", () => + cancel ? { cancel: true } : undefined, + ); + pi.on("session_before_switch", () => + cancel ? { cancel: true } : undefined, + ); + pi.on("session_start", (e) => { + events.push(`start:${e.reason}`); + }); + pi.on("session_shutdown", (e) => { + events.push(`shutdown:${e.reason}`); + }); + }, + root, + bus, + extensionRuntime, + ), + ]; + }; + const loader: ResourceLoader = { + getExtensions: () => ({ + extensions: loaded, + errors: [], + runtime: extensionRuntime, + }), + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => "Offline U3 fixture", + getSystemPromptSource: () => undefined, + getAppendSystemPrompt: () => [], + getAppendSystemPromptSources: () => [], + extendResources() {}, + reload, + }; + await reload(); + const created = await createAgentSession({ + cwd: root, + agentDir, + modelRuntime, + model: baseModel, + thinkingLevel: "off", + tools: [], + resourceLoader: loader, + settingsManager: SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: false }, + cacheWarming: "off", + }), + sessionManager: manager, + sessionStartEvent: startEvent, + }); + return { ...created, loader }; + }; + const manager = memory + ? SessionManager.inMemory(root) + : SessionManager.create(root, join(root, "sessions")); + if (flushed) + manager.appendMessage({ role: "user", content: "fixture", timestamp: 1 }); + const first = await make(manager, { + type: "session_start", + reason: "startup", + }); + const bind = (session: AgentSession) => + session.bindExtensions({ + uiContext: ui, + mode: "tui", + onError: (e) => errors.push(JSON.stringify(e)), + }); + await bind(first.session); + const runtime = new AgentSessionRuntime( + first.session, + { cwd: root, agentDir } as never, + async (options) => { + const next = await make( + options.sessionManager, + options.sessionStartEvent! as never, + ); + return { + ...next, + services: { cwd: root, agentDir } as never, + diagnostics: [], + }; + }, + ); + runtime.setRebindSession(bind); + t.after(async () => { + await runtime.dispose(); + if (originalConfig === undefined) delete process.env.GENTLE_PI_CONFIG_HOME; + else process.env.GENTLE_PI_CONFIG_HOME = originalConfig; + rmSync(root, { recursive: true, force: true }); + assert.equal(providerCalls, 0); + assert.deepEqual(errors, []); + }); + const enter = async ( + name: keyof typeof profiles = "team", + session: AgentSession = runtime.session, + ) => { + selection = name; + const command = session.extensionRunner.getCommand("gentle:profiles"); + assert.ok(command); + await command.handler("", session.extensionRunner.createCommandContext()); + }; + const binding = () => + readSessionProfileBinding(runtime.session.sessionManager.getSessionId()); + const family = () => + runtime.session.sessionManager + .getBranch() + .filter( + (e) => e.type === "custom" && e.customType === SESSION_PROFILE_CUSTOM_TYPE, + ); + const protectedBytes = () => [ + readFileSync(profilesPath, "utf8"), + existsSync(join(config, "models.json")), + existsSync(join(agentDir, "settings.json")), + existsSync(join(root, ".pi", "subagents.json")), + ]; + return { + root, + runtime, + manager, + notices, + events, + errors, + profiles, + saveProfiles, + enter, + binding, + family, + protectedBytes, + api: () => currentApi!, + authCalls: () => authCalls, + setAuth: (fn: typeof auth) => { + auth = fn; + }, + cancel: (value: boolean) => { + cancel = value; + }, + }; +} + +for (const memory of [false, true]) + for (const flushed of [false, true]) + test(`U3 Enter public component persists or explicitly reports preflush (${memory}, ${flushed})`, async (t) => { + const f = await fixture(t, memory, flushed), + before = f.protectedBytes(); + await f.enter(); + assert.equal( + f.family().length, + 1, + "Enter appends one public profile record before publication", + ); + assert.equal(f.authCalls(), 0); + assert.deepEqual(f.protectedBytes(), before); + // A flushed in-memory session never corroborates its append (published + // append controller: append-not-corroborated), so Enter adopts nothing. + if (memory && flushed) { + assert.equal(f.binding(), undefined); + assert.match(f.notices.at(-1)?.text ?? "", /Session profile authority is unavailable/); + } else { + assert.equal(f.binding()?.name, "team"); + assert.deepEqual(f.binding()?.modelProfiles, f.profiles.team); + assert.match( + f.notices.at(-1)?.text ?? "", + memory || !flushed ? /not yet persisted/ : /persisted/, + ); + } + await f.runtime.session.reload(); + assert.equal( + f.binding()?.name, + memory || !flushed ? undefined : "team", + "reload restores only disk-corroborated state", + ); + assert.equal( + f.authCalls(), + 0, + "no restoration-time orchestrator application", + ); + }); + +test("U3 Enter composes append, exact publication and existing live controls without shared writes", async (t) => { + const f = await fixture(t), + before = f.protectedBytes(); + f.setAuth(async () => { + assert.equal(f.binding()?.name, "live", "publication precedes live effects"); + assert.equal(f.family().length, 1); + assert.ok( + readFileSync(f.runtime.session.sessionFile!, "utf8").includes( + SESSION_PROFILE_CUSTOM_TYPE, + ), + ); + return { type: "api-key" }; + }); + await f.enter("live"); + assert.equal(f.runtime.session.model?.id, "gpt-5.2"); + assert.equal(f.runtime.session.thinkingLevel, "high"); + assert.equal(f.authCalls(), 1); + assert.deepEqual(f.protectedBytes(), before); + await f.enter("empty"); + assert.deepEqual(f.binding()?.modelProfiles, {}); + assert.equal(f.authCalls(), 1, "empty profile does not switch model"); +}); + +for (const failure of ["before-append", "EISDIR"] as const) + test(`U3 failed Enter ${failure} adopts nothing and applies no live effects`, async (t) => { + const f = await fixture(t); + await f.enter(); + const file = f.manager.getSessionFile()!, + disk = readFileSync(file, "utf8"), + before = f.protectedBytes(); + const append = f.manager.appendCustomEntry.bind(f.manager); + if (failure === "EISDIR") { + renameSync(file, `${file}.saved`); + mkdirSync(file); + } else + f.manager.appendCustomEntry = () => { + throw Error("fixture rejected append"); + }; + await f.enter("live"); + assert.equal(f.authCalls(), 0); + assert.equal(f.runtime.session.model?.id, "gpt-4o"); + assert.equal(f.binding()?.name, failure === "EISDIR" ? undefined : "team"); + assert.equal(f.notices.at(-1)?.severity, "warning"); + assert.doesNotMatch(f.notices.at(-1)?.text ?? "", /bound profile "live"/); + assert.deepEqual(f.protectedBytes(), before); + f.manager.appendCustomEntry = append; + if (failure === "EISDIR") { + rmSync(file, { recursive: true }); + renameSync(`${file}.saved`, file); + } + assert.equal( + readFileSync(file, "utf8"), + disk, + "rejected entry did not reach disk", + ); + await f.runtime.session.reload(); + assert.equal( + f.binding()?.name, + "team", + "quarantine survives module replacement", + ); + assert.equal(f.authCalls(), 0); + }); + +for (const customType of [ + SESSION_PROFILE_CUSTOM_TYPE, + "gentle-pi.session-profile/v99", +]) + test(`U3 terminal invalid/future selection ${customType} blocks until explicit recovery`, async (t) => { + const f = await fixture(t); + await f.enter(); + f.manager.appendCustomEntry(customType, { invalid: true }); + await f.runtime.session.reload(); + assert.equal(f.binding(), undefined); + await f.enter(); + assert.equal(f.binding()?.name, "team"); + assert.equal(f.authCalls(), 0); + }); + +for (const memory of [false, true]) + for (const cut of ["before", "at", "root", "label"] as const) + test(`U3 actual runtime fork retained cut ${cut} (${memory})`, async (t) => { + const f = await fixture(t, memory); + const root = f.manager.getBranch()[0].id; + await f.enter(); + const profile = f.manager.getLeafId()!; + const user = f.manager.appendMessage({ + role: "user", + content: "cut", + timestamp: 2, + }); + f.manager.appendLabelChange(profile, "profile-label"); + const before = readSessionProfileBinding(f.manager.getSessionId()); + const target = + cut === "root" ? root : cut === "at" || cut === "label" ? profile : user; + const result = await f.runtime.fork(target, { + position: cut === "root" || cut === "before" ? "before" : "at", + }); + assert.equal(result.cancelled, false); + assert.equal( + f.binding()?.name, + cut === "root" || memory ? undefined : "team", + "only retained corroborated records restore", + ); + assert.equal(f.authCalls(), 0); + // In memory the flushed append is never corroborated, so nothing was bound. + assert.deepEqual(before?.modelProfiles, memory ? undefined : f.profiles.team); + assert.ok(f.events.includes("shutdown:fork")); + assert.ok(f.events.includes("start:fork")); + }); + +test("U3 public runtime cancellation and unsaved rejection preserve explicit authority", async (t) => { + const f = await fixture(t, false, false); + await f.enter(); + const leaf = f.manager.getLeafId()!; + const branch = f.manager.getBranch(), + before = f.binding(); + f.cancel(true); + assert.equal((await f.runtime.fork(leaf, { position: "at" })).cancelled, true); + assert.equal((await f.runtime.newSession()).cancelled, true); + assert.deepEqual(f.manager.getBranch(), branch); + assert.deepEqual(f.binding(), before); + f.cancel(false); + await assert.rejects( + f.runtime.fork(leaf, { position: "at" }), + /has not been saved/, + ); + assert.deepEqual(f.binding(), before); + assert.equal( + f.events.some((e) => e.startsWith("shutdown")), + false, + ); +}); + +test("U3 actual new/resume/tree/reload revoke stale public contexts without restoration effects", async (t) => { + const f = await fixture(t); + await f.enter(); + const file = f.manager.getSessionFile()!, + old = f.runtime.session; + const profile = f.manager.getLeafId()!; + const later = f.manager.appendCustomEntry("ordinary", {}); + await old.navigateTree(profile); + assert.equal(f.binding()?.name, "team"); + await old.navigateTree(later); + assert.equal(f.binding()?.name, "team"); + await f.runtime.newSession(); + assert.equal(f.binding(), undefined); + await assert.rejects(f.enter("live", old)); + assert.equal(f.authCalls(), 0); + await f.runtime.switchSession(file); + assert.equal(f.binding()?.name, "team"); + await f.runtime.session.reload(); + assert.equal(f.binding()?.name, "team"); + assert.equal(f.authCalls(), 0); + assert.ok(f.events.includes("start:new")); + assert.ok(f.events.includes("start:resume")); +}); + +for (const transition of ["fork", "tree", "reload", "new"] as const) + test(`U3 pending Enter serializes before ${transition} on actual public SDK`, async (t) => { + const f = await fixture(t); + const leaf = f.manager.getLeafId()!; + let release!: () => void, started!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + f.setAuth(() => { + started(); + return new Promise((resolve) => { + release = () => resolve({ type: "api-key" }); + }); + }); + const enter = f.enter("live"); + // RED must fail an assertion, not hang waiting for a nonexistent live call. + await Promise.race([ready, enter]); + assert.equal(f.authCalls(), 1, "Enter reaches the existing live control"); + let transitioned = false; + const move = ( + transition === "fork" + ? f.runtime.fork(leaf, { position: "at" }) + : transition === "tree" + ? f.runtime.session.navigateTree(leaf) + : transition === "reload" + ? f.runtime.session.reload() + : f.runtime.newSession() + ).then(() => { + transitioned = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + const wasSerialized = !transitioned; + release(); + await Promise.all([enter, move]); + assert.equal( + wasSerialized, + true, + "transition cannot copy/change/detach while Enter awaits authentication", + ); + assert.equal(f.authCalls(), 1); + }); From ec90de8dc4e36a2f605971c447c3a5a86815072d Mon Sep 17 00:00:00 2001 From: Jesus Vazquez Date: Fri, 9 Oct 2026 11:26:43 -0600 Subject: [PATCH 15/15] fix(profiles): skip the Enter notice when its ctx went stale while queued Refs #1064 --- extensions/gentle-ai.ts | 4 +++- tests/gentle-ai.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index df5c164df..27c345a81 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -4288,7 +4288,9 @@ async function runProfilesPanelAction( ); return true; }); - if (selected === undefined) + // A selection skipped by a reload or session replacement leaves a stale + // ctx; only a still-active command reports the missing attachment. + if (selected === undefined && isCommandContextActive(ctx)) ctx.ui.notify( `el Gentleman cannot bind profile "${result.name}": the current session attachment is unavailable. No profile or live model change was applied.`, "warning", diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index 2e250fd4c..4a6da0670 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -17,7 +17,7 @@ import type { import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts"; import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts"; import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; -import { createSessionProfileIntegration } from "../lib/session-profile-integration.ts"; +import { createSessionProfileIntegration, runCurrentSessionProfileSelection } from "../lib/session-profile-integration.ts"; import type { AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts"; type LiveSession = Pick; import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts"; @@ -3753,6 +3753,36 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca } }); +test("Enter skipped by a session replacement while queued never touches the stale ctx", async (t) => { + const { fixture, storePath, writeStore } = profilesStoreFixture(t); + writeStore({ team: { worker: { model: "openai/alpha" } } }, undefined); + resetSessionProfileBindingsForTesting(); + t.after(() => resetSessionProfileBindingsForTesting()); + const manager = profileActionManager(fixture.root); + let stale = false; + const notifications: string[] = []; + const ui = { notify(message: string) { notifications.push(message); } }; + const ctx = { + cwd: fixture.root, + hasUI: true, + get ui() { if (stale) throw new Error("stale ctx"); return ui; }, + get sessionManager() { if (stale) throw new Error("stale ctx"); return manager; }, + } as unknown as ExtensionContext; + let release!: () => void; + const holder = runCurrentSessionProfileSelection(manager, () => new Promise((resolve) => { release = resolve; })); + const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel: (): ThinkingLevel => "medium" }; + const enter = __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" }, {}); + await new Promise((resolve) => setImmediate(resolve)); + const file = manager.getSessionFile(); + manager.getSessionFile = () => `${file}.replaced`; + stale = true; + release(); + await holder; + await enter; + assert.deepEqual(notifications, [], "no notice is sent through the stale ctx"); + assert.equal(readSessionProfileBinding(manager.getSessionId()), undefined); +}); + test("a keeps the legacy global apply semantics", async (t) => { const { fixture, storePath, writeStore } = profilesStoreFixture(t); writeStore({ team: { worker: { model: "openai/alpha" } }, old: {} }, "old");