Skip to content

Commit f3bfcd4

Browse files
committed
mason: SOFT ctx-flush and HARD-fold memory expiry determinism (D16a/D16c)
1 parent eee3753 commit f3bfcd4

6 files changed

Lines changed: 205 additions & 50 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,65 @@ describe("renderM0Pi sibling-block layout (OpenCode parity)", () => {
12991299
closeQuietly(db);
13001300
}
13011301
});
1302+
1303+
it("HARD fold binds memory expiry cutoff and materializedAt to one timestamp", () => {
1304+
const db = createTestDb();
1305+
const cwd = mkdtempSync(join(tmpdir(), "pi-d16c-"));
1306+
try {
1307+
const state = piState("ses-pi-d16c", cwd);
1308+
insertMemory(db, {
1309+
projectPath: state.projectIdentity,
1310+
category: "KNOWN_ISSUES",
1311+
content: "Pi D16c expiry-gap memory",
1312+
expiresAt: 10_500,
1313+
});
1314+
insertMemory(db, {
1315+
projectPath: state.projectIdentity,
1316+
category: "ARCHITECTURE",
1317+
content: "Pi D16c permanent anchor",
1318+
});
1319+
1320+
const realNow = Date.now;
1321+
const foldAt = 10_000;
1322+
let nowCalls = 0;
1323+
Date.now = () => {
1324+
nowCalls += 1;
1325+
return nowCalls === 1 ? foldAt : 99_000;
1326+
};
1327+
1328+
try {
1329+
state.hardSignals = {
1330+
systemHash: "fold-a",
1331+
modelKey: "model-v1",
1332+
cacheExpired: false,
1333+
lastResponseTime: 0,
1334+
};
1335+
const first = materializeM0Pi(state, db);
1336+
expect(first.m0).toContain("Pi D16c expiry-gap memory");
1337+
expect(first.snapshotMarkers.materializedAt).toBe(foldAt);
1338+
1339+
nowCalls = 0;
1340+
state.hardSignals = {
1341+
systemHash: "fold-b",
1342+
modelKey: "model-v1",
1343+
cacheExpired: false,
1344+
lastResponseTime: 0,
1345+
};
1346+
const second = materializeM0Pi(state, db);
1347+
expect(second.m0).toContain("Pi D16c expiry-gap memory");
1348+
expect(
1349+
second.m0.match(/<project-memory>[\s\S]*?<\/project-memory>/)?.[0],
1350+
).toBe(
1351+
first.m0.match(/<project-memory>[\s\S]*?<\/project-memory>/)?.[0],
1352+
);
1353+
} finally {
1354+
Date.now = realNow;
1355+
}
1356+
} finally {
1357+
rmSync(cwd, { recursive: true, force: true });
1358+
closeQuietly(db);
1359+
}
1360+
});
13021361
});
13031362

13041363
describe("mustMaterializePi — SOFT/HARD taxonomy (parity with OpenCode)", () => {

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,7 +1220,7 @@ function readFrozenM0InputsPi(
12201220
projectUserProfileVersion: globalState?.projectUserProfileVersion ?? 0,
12211221
projectDocsHash: docs.canonicalHash,
12221222
sessionFactsVersion: getSessionFactsVersion(db, state.sessionId),
1223-
materializedAt: Date.now(),
1223+
materializedAt: memoryCutoff ?? Date.now(),
12241224
upgradeState: `${PI_M0_UPGRADE_STATE}:${
12251225
compartments.some((c) => c.legacy === 1) ? "legacy" : "ready"
12261226
}`,
@@ -1305,8 +1305,10 @@ export function materializeM0Pi(
13051305
// Phase 1 (no lock): read markers + render. Rendering can be slow, so we do
13061306
// it OUTSIDE the write lock to keep the BEGIN IMMEDIATE critical section tiny.
13071307
const docs = readProjectDocsCanonical(state.projectDirectory);
1308-
const frozen = readFrozenM0InputsPi(state, db, docs);
1308+
const foldMaterializedAt = Date.now();
1309+
const frozen = readFrozenM0InputsPi(state, db, docs, foldMaterializedAt);
13091310
const snapshotMarkers = frozen.markers;
1311+
13101312
const snapshotMemories = frozen.memories;
13111313
const snapshotCompartments = frozen.compartments;
13121314
const snapshotUserProfile = frozen.userProfile;
@@ -1399,10 +1401,8 @@ export function materializeM0Pi(
13991401
db.exec("ROLLBACK");
14001402
throw new PiMaterializeContentionError("snapshot changed before persist");
14011403
}
1402-
// Refresh materializedAt to NOW, right before persist (parity with
1403-
// OpenCode materializeM0). m[1] freezes memory-expiry cutoff at this timestamp;
1404-
// defer passes replay the persisted value verbatim.
1405-
snapshotMarkers.materializedAt = Date.now();
1404+
snapshotMarkers.materializedAt = foldMaterializedAt;
1405+
14061406
const m1Render = renderM1PiWithMetadata(
14071407
state,
14081408
db,

packages/plugin/src/hooks/magic-context/command-handler.test.ts

Lines changed: 7 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -322,16 +322,19 @@ describe("createMagicContextCommandHandler", () => {
322322
expect(getTagStatus(db, "ses-flush", 2)).toBe("dropped");
323323
});
324324

325-
it("clears cached m0 fields for the current session", async () => {
325+
it("is SOFT: keeps cached m0/m1 bytes and invokes onFlush", async () => {
326326
seedCachedM0(db, "ses-flush-cache");
327+
const onFlush = mock(() => {});
327328
const sendNotification = mock(async () => {});
328329
const handler = createMagicContextCommandHandler({
329330
db,
330331
protectedTags: 3,
331332
sendNotification,
333+
onFlush,
332334
});
333335

334-
expect(getCachedM0Row(db, "ses-flush-cache")?.bytes).not.toBeNull();
336+
const before = getCachedM0Row(db, "ses-flush-cache");
337+
expect(before?.bytes).not.toBeNull();
335338

336339
await expectSentinel(
337340
handler["command.execute.before"](
@@ -342,42 +345,8 @@ describe("createMagicContextCommandHandler", () => {
342345
"__CONTEXT_MANAGEMENT_CTX-FLUSH_HANDLED__",
343346
);
344347

345-
expect(getCachedM0Row(db, "ses-flush-cache")).toMatchObject({
346-
bytes: null,
347-
projectMemoryEpoch: null,
348-
userProfileVersion: null,
349-
maxCompartmentSeq: null,
350-
maxMemoryId: null,
351-
maxMutationId: null,
352-
projectDocsHash: null,
353-
materializedAt: null,
354-
sessionFactsVersion: null,
355-
upgradeState: null,
356-
});
357-
});
358-
359-
it("does not clear cached m0 fields for other sessions", async () => {
360-
seedCachedM0(db, "ses-flush-cache");
361-
seedCachedM0(db, "ses-other-cache");
362-
const sendNotification = mock(async () => {});
363-
const handler = createMagicContextCommandHandler({
364-
db,
365-
protectedTags: 3,
366-
sendNotification,
367-
});
368-
369-
await expectSentinel(
370-
handler["command.execute.before"](
371-
{ command: "ctx-flush", sessionID: "ses-flush-cache", arguments: "" },
372-
makeOutput(""),
373-
{},
374-
),
375-
"__CONTEXT_MANAGEMENT_CTX-FLUSH_HANDLED__",
376-
);
377-
378-
expect(getCachedM0Row(db, "ses-flush-cache")?.bytes).toBeNull();
379-
expect(getCachedM0Row(db, "ses-other-cache")?.bytes).not.toBeNull();
380-
expect(getCachedM0Row(db, "ses-other-cache")?.projectMemoryEpoch).toBe(7);
348+
expect(onFlush).toHaveBeenCalledWith("ses-flush-cache");
349+
expect(getCachedM0Row(db, "ses-flush-cache")).toEqual(before);
381350
});
382351
});
383352

packages/plugin/src/hooks/magic-context/command-handler.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
processDreamQueue,
66
} from "../../features/magic-context/dreamer";
77
import { runSidekick } from "../../features/magic-context/sidekick/agent";
8-
import { clearCachedM0M1, getCompartments } from "../../features/magic-context/storage";
8+
import { getCompartments } from "../../features/magic-context/storage";
99
import type { PluginContext } from "../../plugin/types";
1010
import { sessionLog } from "../../shared";
1111
import { isTuiConnected, pushNotification } from "../../shared/rpc-notifications";
@@ -496,7 +496,6 @@ export function createMagicContextCommandHandler(deps: {
496496

497497
if (isFlush) {
498498
result = executeFlush(deps.db, sessionId);
499-
clearCachedM0M1(deps.db, sessionId);
500499
deps.onFlush?.(sessionId);
501500
if (isTuiConnected(sessionId)) {
502501
pushNotification(

packages/plugin/src/hooks/magic-context/inject-compartments.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,124 @@ describe("m[0]/m[1] materialization", () => {
11331133
expect(renderedText(secondMessages[0])).toBe(firstM0);
11341134
});
11351135

1136+
it("SOFT /ctx-flush pass keeps m0 byte-identical, refreshes m1, and avoids first_render", () => {
1137+
db = makeDb();
1138+
const projectDirectory = makeProjectDir();
1139+
const state = readStateFromMeta();
1140+
const hardV1 = {
1141+
systemHash: "sys-v1",
1142+
modelKey: "model-v1",
1143+
cacheExpired: false,
1144+
lastResponseTime: 0,
1145+
};
1146+
const baselineMessages = [userMessage("m1", "hello")];
1147+
injectM0M1({
1148+
db,
1149+
sessionId: SESSION_ID,
1150+
messages: baselineMessages,
1151+
state,
1152+
projectPath: PROJECT_PATH,
1153+
projectDirectory,
1154+
hardSignals: hardV1,
1155+
});
1156+
const m0BeforeFlush =
1157+
baselineMessages[0].parts[0] &&
1158+
renderedText(baselineMessages[0]).match(
1159+
/<session-history>[\s\S]*?<\/session-history>/,
1160+
)?.[0];
1161+
expect(m0BeforeFlush).toBeTruthy();
1162+
1163+
const flushMessages = [userMessage("m2", "after flush")];
1164+
const flushPass = injectM0M1({
1165+
db,
1166+
sessionId: SESSION_ID,
1167+
messages: flushMessages,
1168+
state,
1169+
projectPath: PROJECT_PATH,
1170+
projectDirectory,
1171+
isCacheBustingPass: true,
1172+
});
1173+
expect(flushPass.decision.reason).not.toBe("first_render");
1174+
expect(flushPass.m0RematerializedThisPass).toBe(false);
1175+
const m0AfterFlush = renderedText(flushMessages[0]).match(
1176+
/<session-history>[\s\S]*?<\/session-history>/,
1177+
)?.[0];
1178+
expect(m0AfterFlush).toBe(m0BeforeFlush);
1179+
1180+
const deferMessages = [userMessage("m3", "defer")];
1181+
const deferPass = injectM0M1({
1182+
db,
1183+
sessionId: SESSION_ID,
1184+
messages: deferMessages,
1185+
state,
1186+
projectPath: PROJECT_PATH,
1187+
projectDirectory,
1188+
});
1189+
expect(deferPass.m0RematerializedThisPass).toBe(false);
1190+
expect(renderedText(deferMessages[0])).toBe(renderedText(flushMessages[0]));
1191+
});
1192+
1193+
it("HARD fold binds memory expiry cutoff and materializedAt to one timestamp", () => {
1194+
db = makeDb();
1195+
const projectDirectory = makeProjectDir();
1196+
insertMemory(db, {
1197+
projectPath: PROJECT_PATH,
1198+
category: "KNOWN_ISSUES",
1199+
content: "D16c expiry-gap memory",
1200+
expiresAt: 10_500,
1201+
});
1202+
insertMemory(db, {
1203+
projectPath: PROJECT_PATH,
1204+
category: "ARCHITECTURE",
1205+
content: "D16c permanent anchor",
1206+
});
1207+
1208+
const realNow = Date.now;
1209+
const foldAt = 10_000;
1210+
let nowCalls = 0;
1211+
Date.now = () => {
1212+
nowCalls += 1;
1213+
return nowCalls === 1 ? foldAt : 99_000;
1214+
};
1215+
1216+
try {
1217+
const state = readStateFromMeta();
1218+
const hard = {
1219+
systemHash: "fold-a",
1220+
modelKey: "model-v1",
1221+
cacheExpired: false,
1222+
lastResponseTime: 0,
1223+
};
1224+
const first = materializeM0({
1225+
db,
1226+
sessionId: SESSION_ID,
1227+
state,
1228+
projectPath: PROJECT_PATH,
1229+
projectDirectory,
1230+
hardSignals: hard,
1231+
});
1232+
expect(first.m0Text).toContain("D16c expiry-gap memory");
1233+
expect(getOrCreateSessionMeta(db, SESSION_ID).cachedM0MaterializedAt).toBe(foldAt);
1234+
1235+
nowCalls = 0;
1236+
const state2 = readStateFromMeta();
1237+
const second = materializeM0({
1238+
db,
1239+
sessionId: SESSION_ID,
1240+
state: state2,
1241+
projectPath: PROJECT_PATH,
1242+
projectDirectory,
1243+
hardSignals: { ...hard, systemHash: "fold-b" },
1244+
});
1245+
expect(second.m0Text).toContain("D16c expiry-gap memory");
1246+
expect(second.m0Text.match(/<project-memory>[\s\S]*?<\/project-memory>/)?.[0]).toBe(
1247+
first.m0Text.match(/<project-memory>[\s\S]*?<\/project-memory>/)?.[0],
1248+
);
1249+
} finally {
1250+
Date.now = realNow;
1251+
}
1252+
});
1253+
11361254
it("does NOT drift-refold on a defer pass when m[1] is the empty placeholder (tiny-baseline guard)", () => {
11371255
// Regression: the +15% drift refold must key off GENUINE accumulated
11381256
// delta, not the placeholder. With a tiny m[0], the ~80-byte empty

packages/plugin/src/hooks/magic-context/inject-compartments.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,11 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result {
14981498
canonicalHash: "",
14991499
};
15001500

1501+
// One timestamp for the whole HARD fold: memory expiry cutoff at read time must
1502+
// match persisted materializedAt (defer replays that value). Live Date.now() at
1503+
// read vs a later Date.now() at persist created a determinism gap inside the fold.
1504+
const foldMaterializedAt = Date.now();
1505+
15011506
options.db.exec("BEGIN");
15021507
try {
15031508
workspace = resolveWorkspaceRenderContext({
@@ -1532,11 +1537,16 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result {
15321537
options.db,
15331538
workspace.expandedIdentities,
15341539
["active", "permanent"],
1535-
Date.now(),
1540+
foldMaterializedAt,
15361541
workspace.ownIdentities,
15371542
workspace.shareCategories,
15381543
)
1539-
: getMemoriesByProject(options.db, projectPath, ["active", "permanent"])
1544+
: getMemoriesByProject(
1545+
options.db,
1546+
projectPath,
1547+
["active", "permanent"],
1548+
foldMaterializedAt,
1549+
)
15401550
: [];
15411551
userMemories = safeGetActiveUserMemories(options.db);
15421552
options.db.exec("COMMIT");
@@ -1599,7 +1609,7 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result {
15991609

16001610
if (m0Text.length === 0) m0Text = M0_EMPTY_BODY;
16011611
const m0Bytes = Buffer.from(m0Text, "utf8");
1602-
snapshotMarkers.materializedAt = Date.now();
1612+
snapshotMarkers.materializedAt = foldMaterializedAt;
16031613
const renderedMemoryIds = trimmed.renderOrder.map((m) => m.id);
16041614
const preRenderedKeyFilesBlock = preRenderKeyFilesBlock(options);
16051615
const phase3ProjectDocsHash = projectDirectory ? computeProjectDocsHash(projectDirectory) : "";
@@ -1640,7 +1650,7 @@ export function materializeM0(options: M0M1RenderOptions): MaterializeM0Result {
16401650
? (getMaxMemoryMutationId(options.db, projectPath) ?? 0)
16411651
: 0,
16421652
projectDocsHash: phase3ProjectDocsHash,
1643-
materializedAt: Date.now(),
1653+
materializedAt: foldMaterializedAt,
16441654
sessionFactsVersion: getSessionFactsVersion(options.db, options.sessionId),
16451655
upgradeState: getUpgradeState(options.db, options.sessionId),
16461656
// HARD-bust markers are flight-constant (system/tool/model identity of

0 commit comments

Comments
 (0)