Skip to content

Commit 7d907ae

Browse files
fix(cache): project-docs change no longer forces an m[0] fold
A content change to ARCHITECTURE.md/STRUCTURE.md was a HARD mustMaterialize trigger, so any on-disk docs change folded m[0] and busted the entire cached prefix. In production this fired every time the dreamer's maintain-docs task rewrote the docs — a real m[0] bust for every active session on that project (confirmed live: cached prefix 599,885B → 28,187B, reason=project_docs_hash). Project docs are slowly-changing reference material living in m[0]; they now follow the 'Deliberately NOT triggers' pattern (like new compartments / additive memories): ride along and fold into m[0] on the NEXT natural hard bust, never force one. Removed projectDocsHash as a DECISION input at 6 sites (both harnesses): mustMaterialize, the m[1] Phase-3 contention stale-check, and the sibling CAS. KEPT computed-at-fold + stored, so a natural fold always reads fresh docs (readProjectDocsCanonical) and persists the hash matching the bytes it rendered. CAS removal is safe because the byte compare runs first — a byte-different m[0] still rejects; only docs-hash-only drift with identical bytes now matches. Also fixes analyze-cache-busts.ts: verdict now compares the cached prefix against the PREVIOUS request's last breakpoint (the old check used the current request's moved-tail breakpoint and stamped normal tail growth as BUST); timestamps include the date (multi-day dump sets were ambiguous). Plugin 2010/0, Pi 445/0. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 4883f88 commit 7d907ae

5 files changed

Lines changed: 335 additions & 32 deletions

File tree

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

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "bun:test";
2-
import { mkdtempSync } from "node:fs";
2+
import { mkdtempSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { appendCompartments } from "@magic-context/core/features/magic-context/compartment-storage";
@@ -1078,6 +1078,94 @@ describe("injectM0M1Pi", () => {
10781078
closeQuietly(db);
10791079
}
10801080
});
1081+
1082+
it("soft m1 refresh CAS rejects byte-different m[0] even when non-doc markers match", () => {
1083+
const db = createTestDb();
1084+
const cwd = mkdtempSync(join(tmpdir(), "pi-m1-soft-cas-bytes-"));
1085+
const originalExec = db.exec.bind(db);
1086+
try {
1087+
const state = piState("ses-pi-m1-soft-cas-bytes", cwd);
1088+
injectM0M1Pi(
1089+
state,
1090+
db,
1091+
[userMessage("hello", 10)] as never,
1092+
undefined,
1093+
true,
1094+
);
1095+
const siblingM0 = Buffer.from(
1096+
`<session-history>${"byte mismatch ".repeat(300)}</session-history>`,
1097+
"utf8",
1098+
);
1099+
let injectedSibling = false;
1100+
db.exec = ((sql: string) => {
1101+
if (sql === "BEGIN IMMEDIATE" && !injectedSibling) {
1102+
injectedSibling = true;
1103+
db.prepare(
1104+
"UPDATE session_meta SET cached_m0_bytes = ?, cached_m1_bytes = ? WHERE session_id = ?",
1105+
).run(
1106+
siblingM0,
1107+
Buffer.from("sibling cached pi m1 byte mismatch", "utf8"),
1108+
state.sessionId,
1109+
);
1110+
}
1111+
return originalExec(sql);
1112+
}) as typeof db.exec;
1113+
1114+
const bust = [userMessage("bust", 11)];
1115+
const result = injectM0M1Pi(state, db, bust as never, undefined, true);
1116+
1117+
expect(injectedSibling).toBe(true);
1118+
expect(result.m0Materialized).toBe(false);
1119+
expect(textOf(bust[0] as never)).toBe(siblingM0.toString("utf8"));
1120+
expect(textOf(bust[1] as never)).toBe(
1121+
"sibling cached pi m1 byte mismatch",
1122+
);
1123+
} finally {
1124+
db.exec = originalExec as typeof db.exec;
1125+
closeQuietly(db);
1126+
}
1127+
});
1128+
1129+
it("soft m1 refresh CAS treats docs-hash-only marker drift as a match", () => {
1130+
const db = createTestDb();
1131+
const cwd = mkdtempSync(join(tmpdir(), "pi-m1-soft-cas-docs-"));
1132+
const originalExec = db.exec.bind(db);
1133+
try {
1134+
const state = piState("ses-pi-m1-soft-cas-docs", cwd);
1135+
const first = [userMessage("hello", 10)];
1136+
injectM0M1Pi(state, db, first as never, undefined, true);
1137+
const baselineM0 = textOf(first[0] as never);
1138+
insertMemory(db, {
1139+
projectPath: state.projectIdentity,
1140+
category: "ARCHITECTURE",
1141+
content: "Pi docs-hash-only CAS delta memory",
1142+
sourceType: "agent",
1143+
});
1144+
let changedDocsMarker = false;
1145+
db.exec = ((sql: string) => {
1146+
if (sql === "BEGIN IMMEDIATE" && !changedDocsMarker) {
1147+
changedDocsMarker = true;
1148+
db.prepare(
1149+
"UPDATE session_meta SET cached_m0_project_docs_hash = ? WHERE session_id = ?",
1150+
).run("docs-only-marker-drift", state.sessionId);
1151+
}
1152+
return originalExec(sql);
1153+
}) as typeof db.exec;
1154+
1155+
const bust = [userMessage("bust", 11)];
1156+
const result = injectM0M1Pi(state, db, bust as never, undefined, true);
1157+
1158+
expect(changedDocsMarker).toBe(true);
1159+
expect(result.m0Materialized).toBe(false);
1160+
expect(textOf(bust[0] as never)).toBe(baselineM0);
1161+
expect(textOf(bust[1] as never)).toContain(
1162+
"Pi docs-hash-only CAS delta memory",
1163+
);
1164+
} finally {
1165+
db.exec = originalExec as typeof db.exec;
1166+
closeQuietly(db);
1167+
}
1168+
});
10811169
});
10821170

10831171
describe("renderM0Pi sibling-block layout (OpenCode parity)", () => {
@@ -1283,4 +1371,74 @@ describe("mustMaterializePi — SOFT/HARD taxonomy (parity with OpenCode)", () =
12831371
closeQuietly(db);
12841372
}
12851373
});
1374+
1375+
it("does NOT materialize m[0] on a project docs hash change", () => {
1376+
const db = createTestDb();
1377+
const cwd = mkdtempSync(join(tmpdir(), "pi-tax-docs-soft-"));
1378+
try {
1379+
const state = {
1380+
...piState("ses-pi-tax-docs-soft", cwd),
1381+
hardSignals: baseHard,
1382+
};
1383+
writeFileSync(join(cwd, "ARCHITECTURE.md"), "# Old Pi docs\n");
1384+
injectM0M1Pi(
1385+
state,
1386+
db,
1387+
[userMessage("hi", 10)] as never,
1388+
undefined,
1389+
true,
1390+
);
1391+
1392+
writeFileSync(join(cwd, "ARCHITECTURE.md"), "# New Pi docs\n");
1393+
1394+
expect(mustMaterializePi(state, db)).toEqual({
1395+
value: false,
1396+
reason: null,
1397+
});
1398+
} finally {
1399+
closeQuietly(db);
1400+
}
1401+
});
1402+
1403+
it("folds current project docs on the next natural HARD materialization", () => {
1404+
const db = createTestDb();
1405+
const cwd = mkdtempSync(join(tmpdir(), "pi-tax-docs-hard-"));
1406+
try {
1407+
const state = {
1408+
...piState("ses-pi-tax-docs-hard", cwd),
1409+
hardSignals: baseHard,
1410+
};
1411+
writeFileSync(join(cwd, "ARCHITECTURE.md"), "# Old Pi architecture\n");
1412+
const first = [userMessage("hi", 10)];
1413+
injectM0M1Pi(state, db, first as never, undefined, true);
1414+
expect(textOf(first[0] as never)).toContain("Old Pi architecture");
1415+
1416+
writeFileSync(
1417+
join(cwd, "ARCHITECTURE.md"),
1418+
"# Updated Pi architecture\nFresh Pi docs folded on hard bust.\n",
1419+
);
1420+
const changed = {
1421+
...state,
1422+
hardSignals: { ...baseHard, systemHash: "sys-v2" },
1423+
};
1424+
const second = [userMessage("hi again", 11)];
1425+
const result = injectM0M1Pi(
1426+
changed,
1427+
db,
1428+
second as never,
1429+
undefined,
1430+
true,
1431+
);
1432+
1433+
expect(result.m0Materialized).toBe(true);
1434+
expect(result.m0Reason).toBe("system_hash");
1435+
expect(textOf(second[0] as never)).toContain("Updated Pi architecture");
1436+
expect(textOf(second[0] as never)).toContain(
1437+
"Fresh Pi docs folded on hard bust.",
1438+
);
1439+
expect(textOf(second[0] as never)).not.toContain("Old Pi architecture");
1440+
} finally {
1441+
closeQuietly(db);
1442+
}
1443+
});
12861444
});

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -907,9 +907,6 @@ export function mustMaterializePi(
907907
if (meta.cachedM0UpgradeState !== current.upgradeState) {
908908
return { value: true, reason: "renderer_upgrade" };
909909
}
910-
if (current.projectDocsHash !== (meta.cachedM0ProjectDocsHash ?? "")) {
911-
return { value: true, reason: "project_docs_change" };
912-
}
913910
if (
914911
current.workspaceFingerprint !== null ||
915912
(meta.cachedM0WorkspaceFingerprint ?? null) !== null
@@ -943,6 +940,9 @@ export function mustMaterializePi(
943940
// maxMemoryId watermark, so they must not bust the m[0] cache. Memory
944941
// mutations use cachedM0MaxMemoryMutationId as an m[1] reconcile cursor,
945942
// not as a materialization trigger; keep it out of this trigger set.
943+
// projectDocsHash is also NOT a trigger: docs-only edits ride along until a
944+
// natural HARD fold, which reads fresh docs and persists the hash matching the
945+
// bytes it rendered.
946946
// session_facts is retired as a render source (facts = promoted memories),
947947
// so its version is pinned to 0 and never triggers either.
948948
return { value: false, reason: null };
@@ -1361,7 +1361,6 @@ export function materializeM0Pi(
13611361
current.maxCompartmentSeq !== snapshotMarkers.maxCompartmentSeq ||
13621362
current.maxMutationId !== snapshotMarkers.maxMutationId ||
13631363
current.maxMemoryMutationId !== snapshotMarkers.maxMemoryMutationId ||
1364-
current.projectDocsHash !== snapshotMarkers.projectDocsHash ||
13651364
// Inert today (both harnesses pin sessionFactsVersion to 0 — facts are
13661365
// retired in v2), but kept for structural parity with OpenCode
13671366
// materializeM0 so the two stale checks can't silently drift if either
@@ -1830,8 +1829,9 @@ function cachedPiRowMatchesSnapshot(args: {
18301829
rowMarkers.maxMemoryId === args.markers.maxMemoryId &&
18311830
rowMarkers.maxMutationId === args.markers.maxMutationId &&
18321831
rowMarkers.maxMemoryMutationId === args.markers.maxMemoryMutationId &&
1833-
(rowMarkers.projectDocsHash ?? "") ===
1834-
(args.markers.projectDocsHash ?? "") &&
1832+
// Project-docs hash is inert for CAS decisions: byte-different m[0] rows
1833+
// fail the buffer compare above, while hash-only drift with identical bytes
1834+
// must still refresh m[1] against the current cached prefix.
18351835
rowMarkers.materializedAt === args.markers.materializedAt &&
18361836
rowMarkers.sessionFactsVersion === args.markers.sessionFactsVersion &&
18371837
(rowMarkers.upgradeState ?? null) === (args.markers.upgradeState ?? null) &&

packages/plugin/scripts/analyze-cache-busts.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,22 @@ function cachedPrefixBytes(segs: Segment[], divergeIdx: number): { bytes: number
222222
}
223223

224224
function fmtTime(iso: string): string {
225-
// dumps are UTC; show HH:MM:SS UTC for direct correlation with meta.
226-
const m = iso.match(/T(\d{2}:\d{2}:\d{2})/);
227-
return m ? m[1] : iso;
225+
// Dumps are UTC; the dashboard renders local time (UTC+2), so include the date
226+
// here to keep multi-day dump sets unambiguous when correlating views.
227+
const d = new Date(iso);
228+
if (Number.isNaN(d.getTime())) return iso;
229+
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
230+
const dd = String(d.getUTCDate()).padStart(2, "0");
231+
const hh = String(d.getUTCHours()).padStart(2, "0");
232+
const mi = String(d.getUTCMinutes()).padStart(2, "0");
233+
const ss = String(d.getUTCSeconds()).padStart(2, "0");
234+
return `${mm}-${dd} ${hh}:${mi}:${ss} UTC`;
235+
}
236+
237+
function lastBreakpointIndex(segs: Segment[]): number {
238+
let last = -1;
239+
for (let i = 0; i < segs.length; i += 1) if (segs[i].breakpoint) last = i;
240+
return last;
228241
}
229242

230243
function main(): void {
@@ -243,43 +256,42 @@ function main(): void {
243256
console.log(`Session: ${snaps[0].session}`);
244257
console.log(`Dumps: ${snaps.length} (dir: ${opts.dir})`);
245258
console.log("");
259+
console.log("Dashboard times are local (UTC+2); table times are UTC.");
246260
console.log(
247-
"time(UTC) | segs | verdict | first-divergence | cachedPrefix@breakpoint",
261+
"time(UTC) | segs | verdict | first-divergence | prevBytes → curBytes | cachedPrefix@breakpoint",
248262
);
249263
console.log(
250-
"----------|------|---------|-------------------------|------------------------",
264+
"-------------------|------|---------|-------------------------|-----------------------------|------------------------",
251265
);
252266

253267
for (let k = 0; k < snaps.length; k += 1) {
254268
const cur = snaps[k];
255269
if (k === 0) {
256270
console.log(
257-
`${fmtTime(cur.createdAt)} | ${String(cur.segments.length).padStart(4)} | BASE | (first request) |`,
271+
`${fmtTime(cur.createdAt)} | ${String(cur.segments.length).padStart(4)} | BASE | (first request) | |`,
258272
);
259273
continue;
260274
}
261275
const prev = snaps[k - 1];
262276
const idx = firstDivergence(prev.segments, cur.segments);
263277
if (idx === -1) {
264278
console.log(
265-
`${fmtTime(cur.createdAt)} | ${String(cur.segments.length).padStart(4)} | SAME | (identical to prev) |`,
279+
`${fmtTime(cur.createdAt)} | ${String(cur.segments.length).padStart(4)} | SAME | (identical to prev) | |`,
266280
);
267281
continue;
268282
}
269283
const seg = cur.segments[idx] ?? prev.segments[idx];
270-
const lastBreakpointIdx = (() => {
271-
let last = -1;
272-
for (let i = 0; i < cur.segments.length; i += 1) if (cur.segments[i].breakpoint) last = i;
273-
return last;
274-
})();
275-
// STABLE: divergence is only in the growing tail at/after the final
276-
// breakpoint (expected — new turn appended). BUST: divergence lands
277-
// before the final breakpoint, invalidating cached prefix it should keep.
278-
const verdict = idx >= lastBreakpointIdx ? "STABLE" : "BUST";
284+
// The reusable cache was written at PREV's breakpoints. OpenCode moves the
285+
// tail breakpoint forward every request, so judging against CUR's final
286+
// breakpoint mislabels ordinary tail growth as a bust.
287+
const prevLastBreakpoint = lastBreakpointIndex(prev.segments);
288+
const verdict = idx > prevLastBreakpoint ? "STABLE" : "BUST";
289+
const prevPrefix = cachedPrefixBytes(prev.segments, prev.segments.length);
279290
const cp = cachedPrefixBytes(cur.segments, idx);
291+
const byteDelta = `${prevPrefix.bytes.toLocaleString()}B → ${cp.bytes.toLocaleString()}B`;
280292
const segId = seg?.id ?? `seg[${idx}]`;
281293
console.log(
282-
`${fmtTime(cur.createdAt)} | ${String(cur.segments.length).padStart(4)} | ${verdict.padEnd(7)} | ${segId.padEnd(23)} | ${cp.at} (${cp.bytes.toLocaleString()}B)`,
294+
`${fmtTime(cur.createdAt)} | ${String(cur.segments.length).padStart(4)} | ${verdict.padEnd(7)} | ${segId.padEnd(23)} | ${byteDelta.padEnd(27)} | ${cp.at} (${cp.bytes.toLocaleString()}B)`,
283295
);
284296

285297
if ((opts.showDiff || opts.allBusts) && verdict === "BUST") {

0 commit comments

Comments
 (0)