Skip to content

Commit 4fde593

Browse files
committed
merge: pi marker-drain liveness gap report (diagnosis only)
2 parents 5c092d5 + 33000cb commit 4fde593

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Pi deferred compaction-marker drain: liveness gap (m0-only coverage read)
2+
3+
Date: 2026-07-24
4+
Status: diagnosis complete — product fix deferred to a separate cache-safety-reviewed task.
5+
Failing test: `packages/e2e-tests/tests/pi-long-running-session.test.ts`, phase 5
6+
("Pi native compaction entry written", the `waitFor` at lines 385-388). The test is
7+
left RED on purpose: it points at the real gap below.
8+
9+
## Summary
10+
11+
After a normal Pi historian publication, the staged deferred compaction marker
12+
never drains into a native JSONL `compaction` entry unless an unrelated HARD bust
13+
later re-materializes m[0]. A plain user drive turn (execute / force-materialization
14+
pass) reaches the drain code but is skipped every time, because the Jul-6 seam-fix
15+
coverage gate reads the "rendered boundary" from the **m[0] snapshot markers only**,
16+
while every fresh publication renders its compartment into **m[1]** (m[0] folds new
17+
compartments only on a HARD bust). Coverage therefore can never be satisfied by the
18+
publication that staged the marker — it needs a later HARD fold.
19+
20+
This is stricter than OpenCode (which drains on the consuming pass) and stricter than
21+
the deferred-marker doctrine ("deferred work rides the NEXT BUST CYCLE" — an execute
22+
pass is a bust cycle, but it does not advance the m[0] boundary).
23+
24+
## What changed, and when
25+
26+
- Gate introduced: commit `174320e3` "mason: fix pi deferred marker seams"
27+
(2026-07-06). It added `pendingPiMarkerCoveredByRenderedBoundary` and made the
28+
deferred-marker drain conditional on it.
29+
- Gate definition: `packages/pi-plugin/src/context-handler.ts:4057-4066`.
30+
- Gate use in the drain: `packages/pi-plugin/src/context-handler.ts:5181-5227`
31+
(inside `runPipeline`, under `deferredHistoryDrainEligible`).
32+
- Pre-seam-fix behavior (visible in the `174320e3` diff): the drain applied
33+
`applyDeferredPiCompactionMarker` whenever a pending marker existed and
34+
`appendCompaction`/`readBranchEntries` were available — **no rendered-boundary
35+
coverage requirement**. So pre-Jul-6 an execute pass after publish drained the
36+
marker; post-Jul-6 it cannot until the rendered boundary covers the ordinal.
37+
- Test ported before the gate: `b8f9a2d4` (2026-06-11, partial Pi ballast port). The
38+
test's phase 5 assumes the pre-seam-fix "drive turn drains" behavior.
39+
- Exposure: `dcf5d14c` (2026-07-24) fixed the e2e historian fixtures to emit valid v2
40+
tiered compartments, so publication now succeeds and the test progresses far enough
41+
to hit the downstream drain gap (previously it failed earlier, at publication).
42+
43+
## The mechanism (m0-only coverage read)
44+
45+
The drain gate:
46+
47+
```
48+
// packages/pi-plugin/src/context-handler.ts:4057
49+
function pendingPiMarkerCoveredByRenderedBoundary(pending, injection): boolean {
50+
if (!injection || injection.contentionExhausted) return false;
51+
const boundary = injection.renderedBoundary;
52+
if (pending.endMessageId === boundary.endMessageId) return true;
53+
return boundary.ordinal !== null && pending.ordinal <= boundary.ordinal;
54+
}
55+
```
56+
57+
`injection.renderedBoundary` is produced by `injectM0M1Pi`:
58+
59+
```
60+
// packages/pi-plugin/src/inject-compartments-pi.ts:2543-2547
61+
const boundaryId = findCompartmentBoundaryForSnapshot(markers);
62+
const renderedBoundary = resolveRenderedCompartmentBoundary(currentCompartments, boundaryId);
63+
```
64+
65+
`markers` are the **m[0] snapshot markers** (from a fresh m[0] materialization or the
66+
cached m[0] replay). `resolveRenderedCompartmentBoundary`
67+
(`inject-compartments-pi.ts:2298-2311`) returns `{ endMessageId: null, ordinal: null }`
68+
when `boundaryId` is null — i.e. when the m[0] snapshot has no compartment boundary.
69+
70+
The key asymmetry: **new compartments are an m[1] delta, not an m[0] trigger.**
71+
72+
```
73+
// packages/pi-plugin/src/inject-compartments-pi.ts:1159-1162 (mustMaterializePi)
74+
// new_compartment is NOT a trigger (parity with OpenCode — Bug 1 fix): new
75+
// compartments are an m[1] delta (renderM1Pi readNewCompartments WHERE
76+
// sequence > cachedM0Seq ...), folded into m[0] only on a HARD bust.
77+
```
78+
79+
So after a publication:
80+
1. The historian stages the pending marker (`pi-historian-runner.ts:1223-1232`,
81+
`setPendingPiCompactionMarkerState`) and `onPublished` signals a deferred
82+
history-refresh + materialization (`context-handler.ts:3495-3496`).
83+
2. The new compartment renders into **m[1]** (the delta). **m[0] is not
84+
re-materialized** (new_compartment is not a HARD trigger), so the cached m[0]
85+
stays the pre-publication placeholder and its snapshot markers carry **no**
86+
compartment boundary.
87+
3. On the next execute / force-materialization pass the drain block IS entered
88+
(`deferredHistoryDrainEligible` is true), but
89+
`pendingPiMarkerCoveredByRenderedBoundary` sees `renderedBoundary = <none>`
90+
(ordinal null) and skips, preserving the deferred signals.
91+
4. The m[0] boundary only advances when a **HARD bust** re-materializes m[0]
92+
(`mustMaterializePi`: model_change / system_hash / ttl_idle / project_change /
93+
project_memory_change / pending_mutations / renderer_upgrade /
94+
compartment_render_epoch). Only then does `markers` carry the compartment boundary,
95+
`renderedBoundary` cover the pending ordinal, and the drain finally apply.
96+
97+
A normal publication bumps **none** of the HARD triggers. In particular
98+
`pending_mutations` reads `m0_mutation_log`
99+
(`inject-compartments-pi.ts:1022``getMaxM0MutationId`,
100+
`storage-m0-mutation-log.ts:117`), and that log is written **only** by
101+
recomp/merge/upgrade/delete (`queueM0Mutation`, `storage-m0-mutation-log.ts:48`;
102+
callers in `compartment-runner-recomp.ts`), never by normal publication.
103+
104+
Net effect: in a stable session (no model change, no memory/project mutation, no
105+
ttl-idle, no recomp) the native compaction trim never happens after a normal
106+
publication; the pending marker stays staged indefinitely. (Context-window compaction
107+
is unaffected — the m[1] delta carries the summary immediately; only the native
108+
`getBranch()` trim / JSONL `compaction` entry is delayed.)
109+
110+
## OpenCode-vs-Pi asymmetry
111+
112+
OpenCode's deferred-marker drain does **not** gate on a rendered m[0] boundary:
113+
114+
```
115+
// packages/plugin/src/hooks/magic-context/compaction-marker-manager.ts:208
116+
export function applyDeferredCompactionMarker(db, sessionId, pending, directory?) {
117+
...
118+
const boundary = findBoundaryUserMessage(sessionId, pending.endMessageId); // raw messages
119+
...
120+
}
121+
```
122+
123+
OpenCode resolves the trim boundary directly from the raw message list
124+
(`findBoundaryUserMessage`), independent of whether m[0] has folded the compartment.
125+
So OpenCode drains on the consuming pass. This is why the OpenCode long-running twin
126+
(`packages/e2e-tests/tests/long-running-session.test.ts`, phase 6, turns 19-21)
127+
drives its marker drain with ordinary send turns and needs no HARD injection.
128+
129+
Pi's seam-fix gate added the rendered-m[0]-boundary requirement that OpenCode does not
130+
have. The gate's *intent* was sound ("don't drain a marker for a compartment that
131+
isn't rendered anywhere"), but the *implementation* reads "rendered" from the m[0]
132+
snapshot markers only, so an m[1]-rendered compartment — where every fresh publication
133+
lands — never satisfies coverage until a HARD fold moves it into m[0]. That is the
134+
accidental asymmetry.
135+
136+
## Empirical proof (this worktree)
137+
138+
Built `packages/pi-plugin` (`bun run build`) and ran the e2e against the mock provider.
139+
140+
1. Drive execute turns only (pressure turn + execute turns after the publish wait, no
141+
HARD bust): the test fails with `compactions.length === 0`. The MC log
142+
(`$TMPDIR/pi/magic-context/magic-context.log`) shows the drain entered and skipped
143+
on every execute pass:
144+
- `pending ops WILL APPLY — reason=deferred_publication ...`
145+
- `heuristics WILL RUN — reason=force_materialization ...`
146+
- `injected m[0]/m[1] into Pi messages (35 + 518 bytes, materialized=false)`
147+
- `Pi compaction-marker drain skipped: pending ordinal 7 is newer than rendered boundary <none> endMessageId=<none>; preserving deferred signals`
148+
DB state at failure: 3 compartments published (seq 0:1-2, 1:3-4, 2:5-7),
149+
`pending_pi_compaction_marker_state` still staged at ordinal 7, 0 JSONL
150+
`compaction` entries, `compartment_state_lease` empty (historian finished).
151+
152+
2. Same drive turns PLUS one HARD bust (a single `m0_mutation_log` row,
153+
`mutation_type='recomp_boundary_change'`, inserted via the test's existing `writeDb`
154+
helper): the whole test passes — all 8 phases, 27 assertions, including
155+
`fromHook === true` and `pending_compaction_marker_state === null`. The HARD bust
156+
re-materializes m[0] with the compartment boundary, coverage is satisfied, and the
157+
drain writes the native entry.
158+
159+
This proves both halves: (a) a drive turn alone is insufficient (the m0-only coverage
160+
read blocks it), and (b) a HARD bust is sufficient (it advances the m[0] boundary).
161+
162+
## Suggested product fix (for the separate cache-safety-reviewed task)
163+
164+
Make coverage satisfy when the compartment is rendered in **m[0] OR the current m[1]**,
165+
rather than m[0] only — i.e. the drain may apply once the pending ordinal is covered by
166+
what the model actually sees this pass (m[0] fold boundary OR the m[1] new-compartment
167+
delta watermark). Concretely, `pendingPiMarkerCoveredByRenderedBoundary`
168+
(`context-handler.ts:4057`) should accept coverage from the m[1]-rendered compartment
169+
set (e.g. the latest compartment sequence rendered into m[1] this pass), not solely
170+
`injection.renderedBoundary` from the m[0] snapshot markers. This restores parity with
171+
OpenCode's consuming-pass drain and the "next bust cycle" doctrine.
172+
173+
Cache-safety note: the original gate exists to avoid trimming `getBranch()` to a
174+
boundary the model hasn't been shown. Any relaxation must confirm the m[1] delta has
175+
actually rendered the covering compartment this pass (so the trimmed messages are
176+
already summarized in what the model sees) before allowing the native trim — hence a
177+
dedicated, reviewed task rather than an inline test-side workaround.
178+
179+
## Why the test is left red
180+
181+
Injecting an artificial HARD bust into the test would make it pass while encoding the
182+
accidental m0-only behavior as intended — masking the liveness gap. The test stays red
183+
until the product fix above lands; a red test pointing at a real liveness gap is doing
184+
its job.

0 commit comments

Comments
 (0)