Skip to content

Commit b74e237

Browse files
committed
mason: fix historian atomic publish and Pi project marker
1 parent 437432a commit b74e237

15 files changed

Lines changed: 723 additions & 297 deletions

packages/pi-plugin/src/context-handler.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,13 +1519,6 @@ export function registerPiContextHandler(
15191519
previousModelKey !== undefined &&
15201520
currentModelKey !== undefined &&
15211521
previousModelKey !== currentModelKey;
1522-
if (modelChanged) {
1523-
clearPiM0Cache(
1524-
options.db,
1525-
sessionId,
1526-
`model switch ${previousModelKey} -> ${currentModelKey}`,
1527-
);
1528-
}
15291522
if (currentModelKey !== undefined) {
15301523
liveModelBySession.set(sessionId, currentModelKey);
15311524
}

packages/pi-plugin/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1094,7 +1094,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
10941094
});
10951095

10961096
if (result.hashChanged) {
1097-
clearPiM0Cache(db, sessionId, "system prompt hash change");
10981097
// Real prompt-content change. Cache prefix is already
10991098
// busted on this turn. Signal all three independent
11001099
// refresh sets so the next pi.on("context") event

packages/pi-plugin/src/inject-compartments-pi.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,6 +1451,160 @@ describe("mustMaterializePi — SOFT/HARD taxonomy (parity with OpenCode)", () =
14511451
}
14521452
});
14531453

1454+
it("lazy-adopts a NULL cached project marker without a no-switch HARD fold", () => {
1455+
const db = createTestDb();
1456+
const cwd = mkdtempSync(join(tmpdir(), "pi-tax-project-null-a-"));
1457+
const cwdB = mkdtempSync(join(tmpdir(), "pi-tax-project-null-b-"));
1458+
try {
1459+
const state = {
1460+
...piState("ses-pi-tax-project-null", cwd),
1461+
hardSignals: baseHard,
1462+
};
1463+
appendCompartments(db, state.sessionId, [compartment(0, "Alpha")]);
1464+
const first = [userMessage("hi", 10)];
1465+
injectM0M1Pi(state, db, first as never, ["entry-0"]);
1466+
const baselineM0 = textOf(first[0] as never);
1467+
1468+
db.prepare(
1469+
"UPDATE session_meta SET cached_m0_project_identity = NULL WHERE session_id = ?",
1470+
).run(state.sessionId);
1471+
1472+
expect(mustMaterializePi(state, db)).toEqual({
1473+
value: false,
1474+
reason: null,
1475+
});
1476+
const noSwitch = [userMessage("same project", 11)];
1477+
const noSwitchResult = injectM0M1Pi(state, db, noSwitch as never, [
1478+
"entry-0",
1479+
]);
1480+
1481+
expect(noSwitchResult.m0Materialized).toBe(false);
1482+
expect(noSwitchResult.m0Reason).not.toBe("first_render");
1483+
expect(noSwitchResult.m0Reason).not.toBe("project_change");
1484+
expect(textOf(noSwitch[0] as never)).toBe(baselineM0);
1485+
expect(
1486+
db
1487+
.prepare(
1488+
"SELECT cached_m0_project_identity FROM session_meta WHERE session_id = ?",
1489+
)
1490+
.get(state.sessionId),
1491+
).toEqual({ cached_m0_project_identity: state.projectIdentity });
1492+
1493+
const switched = {
1494+
...piState(state.sessionId, cwdB),
1495+
hardSignals: baseHard,
1496+
};
1497+
expect(mustMaterializePi(switched, db)).toEqual({
1498+
value: true,
1499+
reason: "project_change",
1500+
});
1501+
} finally {
1502+
rmSync(cwd, { recursive: true, force: true });
1503+
rmSync(cwdB, { recursive: true, force: true });
1504+
closeQuietly(db);
1505+
}
1506+
});
1507+
1508+
it("HARD: a genuine same-session project switch folds exactly once, then stabilizes", () => {
1509+
const db = createTestDb();
1510+
const cwdA = mkdtempSync(join(tmpdir(), "pi-tax-project-a-"));
1511+
const cwdB = mkdtempSync(join(tmpdir(), "pi-tax-project-b-"));
1512+
try {
1513+
const stateA = {
1514+
...piState("ses-pi-tax-project-switch", cwdA),
1515+
hardSignals: baseHard,
1516+
};
1517+
insertMemory(db, {
1518+
projectPath: stateA.projectIdentity,
1519+
category: "ARCHITECTURE",
1520+
content: "Project A memory must not replay after /cd.",
1521+
});
1522+
const first = [userMessage("hi", 10)];
1523+
injectM0M1Pi(stateA, db, first as never, undefined, true);
1524+
expect(textOf(first[0] as never)).toContain("Project A memory");
1525+
1526+
const stateB = {
1527+
...piState(stateA.sessionId, cwdB),
1528+
hardSignals: baseHard,
1529+
};
1530+
insertMemory(db, {
1531+
projectPath: stateB.projectIdentity,
1532+
category: "ARCHITECTURE",
1533+
content: "Project B memory is the switched project baseline.",
1534+
});
1535+
1536+
const switched = [userMessage("after cd", 11)];
1537+
const switchedResult = injectM0M1Pi(
1538+
stateB,
1539+
db,
1540+
switched as never,
1541+
undefined,
1542+
true,
1543+
);
1544+
expect(switchedResult.m0Materialized).toBe(true);
1545+
expect(switchedResult.m0Reason).toBe("project_change");
1546+
expect(textOf(switched[0] as never)).toContain("Project B memory");
1547+
expect(textOf(switched[0] as never)).not.toContain("Project A memory");
1548+
1549+
const stable = [userMessage("after cd stable", 12)];
1550+
const stableResult = injectM0M1Pi(
1551+
stateB,
1552+
db,
1553+
stable as never,
1554+
undefined,
1555+
false,
1556+
);
1557+
expect(stableResult.m0Materialized).toBe(false);
1558+
expect(stableResult.m0Reason).toBeNull();
1559+
expect(textOf(stable[0] as never)).toBe(textOf(switched[0] as never));
1560+
} finally {
1561+
rmSync(cwdA, { recursive: true, force: true });
1562+
rmSync(cwdB, { recursive: true, force: true });
1563+
closeQuietly(db);
1564+
}
1565+
});
1566+
1567+
it("model and system changes materialize with classified reasons, not first_render", () => {
1568+
const db = createTestDb();
1569+
const cwdModel = mkdtempSync(join(tmpdir(), "pi-tax-model-reason-"));
1570+
const cwdSystem = mkdtempSync(join(tmpdir(), "pi-tax-system-reason-"));
1571+
try {
1572+
const modelState = {
1573+
...piState("ses-pi-tax-model-reason", cwdModel),
1574+
hardSignals: baseHard,
1575+
};
1576+
injectM0M1Pi(modelState, db, [userMessage("hi", 10)] as never);
1577+
const modelChanged = {
1578+
...modelState,
1579+
hardSignals: { ...baseHard, modelKey: "anthropic/sonnet" },
1580+
};
1581+
const modelPass = [userMessage("model", 11)];
1582+
const modelResult = injectM0M1Pi(modelChanged, db, modelPass as never);
1583+
expect(modelResult.m0Materialized).toBe(true);
1584+
expect(modelResult.m0Reason).toBe("model_change");
1585+
expect(modelResult.m0Reason).not.toBe("first_render");
1586+
1587+
const systemState = {
1588+
...piState("ses-pi-tax-system-reason", cwdSystem),
1589+
hardSignals: baseHard,
1590+
};
1591+
injectM0M1Pi(systemState, db, [userMessage("hi", 10)] as never);
1592+
const systemChanged = {
1593+
...systemState,
1594+
hardSignals: { ...baseHard, systemHash: "sys-v2" },
1595+
};
1596+
const systemPass = [userMessage("system", 11)];
1597+
const systemResult = injectM0M1Pi(systemChanged, db, systemPass as never);
1598+
expect(systemResult.m0Materialized).toBe(true);
1599+
expect(systemResult.m0Reason).toBe("system_hash");
1600+
expect(systemResult.m0Reason).not.toBe("first_render");
1601+
} finally {
1602+
rmSync(cwdModel, { recursive: true, force: true });
1603+
rmSync(cwdSystem, { recursive: true, force: true });
1604+
closeQuietly(db);
1605+
}
1606+
});
1607+
14541608
it("an empty current HARD signal is never treated as a change", () => {
14551609
const db = createTestDb();
14561610
const cwd = mkdtempSync(join(tmpdir(), "pi-tax-empty-"));

packages/pi-plugin/src/inject-compartments-pi.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ function injectHistoryBlockIntoFirstUserMessage(
373373
): boolean {
374374
for (let i = 0; i < piMessages.length; i++) {
375375
const msg = piMessages[i];
376-
if (!msg || msg.role !== "user") continue;
376+
if (msg?.role !== "user") continue;
377377

378378
const userMsg = msg as PiUserMessage;
379379
if (typeof userMsg.content === "string") {
@@ -610,6 +610,9 @@ export interface PiM0SnapshotMarkers {
610610
// Captured from PiM0HardSignals at the injection call site.
611611
systemHash: string;
612612
modelKey: string;
613+
// Pi sessions can switch projects in-process (`/cd`). NULL on legacy cached
614+
// rows means unknown/lazy-adopted and must not force a HARD fold by itself.
615+
projectIdentity: string | null;
613616
}
614617

615618
/**
@@ -770,6 +773,7 @@ function getCachedMarkers(
770773
lastBaselineEndMessageId: cachedBoundary,
771774
systemHash: meta.cachedM0SystemHash ?? "",
772775
modelKey: meta.cachedM0ModelKey ?? "",
776+
projectIdentity: meta.cachedM0ProjectIdentity ?? null,
773777
};
774778
}
775779

@@ -859,6 +863,7 @@ function readCurrentMarkersFromCompartments(
859863
lastBaselineEndMessageId: lastBaselineEndMessageId(compartments),
860864
systemHash: (state.hardSignals ?? EMPTY_PI_HARD_SIGNALS).systemHash,
861865
modelKey: (state.hardSignals ?? EMPTY_PI_HARD_SIGNALS).modelKey,
866+
projectIdentity: state.projectIdentity,
862867
};
863868
}
864869

@@ -907,6 +912,15 @@ export function mustMaterializePi(
907912
) {
908913
return { value: true, reason: "system_hash" };
909914
}
915+
// Pi can switch projects within the same session (`/cd`). Legacy cached rows
916+
// have a NULL marker: treat that as unknown/MATCH for lazy adoption so the
917+
// first post-upgrade no-switch pass does not HARD-fold existing sessions.
918+
if (
919+
meta.cachedM0ProjectIdentity !== null &&
920+
meta.cachedM0ProjectIdentity !== state.projectIdentity
921+
) {
922+
return { value: true, reason: "project_change" };
923+
}
910924
// Idle > TTL: self-consuming guard via cachedM0MaterializedAt (parity with
911925
// OpenCode). cacheExpired stays true every pass until lastResponseTime
912926
// updates, so fold only when the last response is newer than the last
@@ -1227,6 +1241,7 @@ function readFrozenM0InputsPi(
12271241
lastBaselineEndMessageId: lastBaselineEndMessageId(compartments),
12281242
systemHash: (state.hardSignals ?? EMPTY_PI_HARD_SIGNALS).systemHash,
12291243
modelKey: (state.hardSignals ?? EMPTY_PI_HARD_SIGNALS).modelKey,
1244+
projectIdentity: state.projectIdentity,
12301245
};
12311246
return { docs, markers, compartments, memories, userProfile, workspace };
12321247
});
@@ -1391,6 +1406,7 @@ export function materializeM0Pi(
13911406
current.maxCompartmentSeq !== snapshotMarkers.maxCompartmentSeq ||
13921407
current.maxMutationId !== snapshotMarkers.maxMutationId ||
13931408
current.maxMemoryMutationId !== snapshotMarkers.maxMemoryMutationId ||
1409+
current.projectIdentity !== snapshotMarkers.projectIdentity ||
13941410
// Inert today (both harnesses pin sessionFactsVersion to 0 — facts are
13951411
// retired in v2), but kept for structural parity with OpenCode
13961412
// materializeM0 so the two stale checks can't silently drift if either
@@ -1428,6 +1444,7 @@ export function materializeM0Pi(
14281444
upgradeState: snapshotMarkers.upgradeState,
14291445
systemHash: snapshotMarkers.systemHash,
14301446
modelKey: snapshotMarkers.modelKey,
1447+
projectIdentity: snapshotMarkers.projectIdentity,
14311448
});
14321449
// Persist the rendered-memory identity in the SAME transaction as the m[0]
14331450
// snapshot (parity with OpenCode materializeM0). `memory_block_ids` /
@@ -1743,6 +1760,7 @@ interface CachedPiM0M1Row {
17431760
cached_m0_upgrade_state: string | null;
17441761
cached_m0_system_hash: string | null;
17451762
cached_m0_model_key: string | null;
1763+
cached_m0_project_identity: string | null;
17461764
cached_m0_last_baseline_end_message_id: string | null;
17471765
memory_block_ids: string | null;
17481766
}
@@ -1792,6 +1810,7 @@ function readCachedPiM0M1Row(
17921810
cached_m0_upgrade_state,
17931811
cached_m0_system_hash,
17941812
cached_m0_model_key,
1813+
cached_m0_project_identity,
17951814
cached_m0_last_baseline_end_message_id,
17961815
memory_block_ids
17971816
FROM session_meta
@@ -1836,6 +1855,7 @@ function markersFromCachedPiRow(
18361855
: null,
18371856
systemHash: row.cached_m0_system_hash ?? "",
18381857
modelKey: row.cached_m0_model_key ?? "",
1858+
projectIdentity: row.cached_m0_project_identity ?? null,
18391859
};
18401860
}
18411861

@@ -1870,6 +1890,8 @@ function cachedPiRowMatchesSnapshot(args: {
18701890
// this process's cached row so the soft-refresh CAS adopts the sibling's m[0].
18711891
(rowMarkers.systemHash ?? "") === (args.markers.systemHash ?? "") &&
18721892
(rowMarkers.modelKey ?? "") === (args.markers.modelKey ?? "") &&
1893+
(rowMarkers.projectIdentity ?? null) ===
1894+
(args.markers.projectIdentity ?? null) &&
18731895
// Workspace fingerprint (parity with OpenCode cachedRowMatchesState):
18741896
// projectMemoryEpoch above only tracks THIS session's own project, but a
18751897
// FOREIGN member's epoch bump changes the workspace fingerprint without
@@ -1881,6 +1903,15 @@ function cachedPiRowMatchesSnapshot(args: {
18811903
);
18821904
}
18831905

1906+
function adoptCachedPiProjectIdentity(
1907+
db: ContextDatabase,
1908+
state: PiM0M1State,
1909+
): void {
1910+
db.prepare(
1911+
"UPDATE session_meta SET cached_m0_project_identity = ? WHERE session_id = ? AND cached_m0_project_identity IS NULL",
1912+
).run(state.projectIdentity, state.sessionId);
1913+
}
1914+
18841915
function decodeCachedM1(row: CachedPiM0M1Row, sessionId: string): string {
18851916
if (!row.cached_m1_bytes) {
18861917
throw new PiMaterializeContentionError(
@@ -2168,6 +2199,10 @@ export function injectM0M1Pi(
21682199
`missing m[0] markers for ${state.sessionId}`,
21692200
);
21702201
}
2202+
if (!materialized && markers.projectIdentity === null) {
2203+
adoptCachedPiProjectIdentity(db, state);
2204+
markers = { ...markers, projectIdentity: state.projectIdentity };
2205+
}
21712206

21722207
if (materialized) {
21732208
// m[1] was rendered and persisted atomically inside materializeM0Pi.

packages/pi-plugin/src/pi-historian-runner.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ async function runHistorianWith(args: {
140140
refreshBoundarySnapshot?: () => ProtectedTailBoundarySnapshot;
141141
providerMessages?: ReturnType<typeof rawMessages>;
142142
beforeRun?: (db: ReturnType<typeof createTestDb>) => void;
143+
ensureProjectRegistered?: Parameters<
144+
typeof runPiHistorian
145+
>[0]["ensureProjectRegistered"];
143146
}) {
144147
const db = createTestDb();
145148
const runner = runnerReturning([...args.outputs]);
@@ -164,6 +167,7 @@ async function runHistorianWith(args: {
164167
boundarySnapshot: args.boundarySnapshot,
165168
refreshBoundarySnapshot: args.refreshBoundarySnapshot,
166169
compartmentLeaseHolderId: holderId,
170+
ensureProjectRegistered: args.ensureProjectRegistered,
167171
});
168172
return { db, runner };
169173
}
@@ -463,6 +467,39 @@ describe("runPiHistorian", () => {
463467
}
464468
});
465469

470+
it("keeps publish succeeded and signaled when post-commit project registration throws", async () => {
471+
const onPublished = mock(() => undefined);
472+
const ensureProjectRegistered = mock(async () => {
473+
throw new Error("embedding provider unavailable");
474+
});
475+
const { db } = await runHistorianWith({
476+
outputs: [successXml("Pi durable fact survives registration outage.")],
477+
onPublished,
478+
ensureProjectRegistered,
479+
});
480+
try {
481+
await new Promise((resolve) => setTimeout(resolve, 0));
482+
expect(onPublished).toHaveBeenCalledTimes(1);
483+
expect(getCompartments(db, "ses-historian")).toHaveLength(1);
484+
expect(
485+
loadProtectedTailMeta(db, "ses-historian").priorBoundaryOrdinal,
486+
).toBe(3);
487+
const projectPath = resolveProjectIdentity(process.cwd());
488+
expect(
489+
getMemoriesByProject(db, projectPath).map((memory) => memory.content),
490+
).toContain("Pi durable fact survives registration outage.");
491+
expect(
492+
db
493+
.prepare(
494+
"SELECT status FROM historian_runs WHERE session_id = ? ORDER BY id DESC LIMIT 1",
495+
)
496+
.get("ses-historian"),
497+
).toEqual({ status: "success" });
498+
} finally {
499+
closeQuietly(db);
500+
}
501+
});
502+
466503
it("queues a Pi-native compaction marker after publication", async () => {
467504
const appendCompaction = mock(() => "compact-1");
468505
const entries = Array.from({ length: 6 }, (_, index) => ({

0 commit comments

Comments
 (0)