21 KiB
Uncovered-chain deterministic compression with recoverability backfill
Fixes jjuraszek/pi-condense#10.
Problem
compressEligible (src/chain-compressor.ts:118-126) permanently skips eligible
chains with zero per-batch summary coverage (reason: "no-summary"). The skip is
structural: once the prune frontier passes a span without a summary body (trivial
batch, skipped-oversized, fully-deduped batch, or a capture miss),
trimBatchToPendingRange excludes it from future capture forever. No amount of
waiting, /pruner now, or /pruner compact recovers it.
Motivating incident (session 2026-08-12T10-47-56): a 639-call chain, ~935k chars
(~300k tokens, ~62% of a 620k context window) stranded live, repeatedly skipped
with no-summary.
Zero coverage is a normal-path occurrence, not exotic - the fix must be harmless when triggered: deterministic, zero LLM calls, fail-closed.
Decisions (user-ratified)
| # | Decision |
|---|---|
| D1 | Body contract: reuse rangeSummaryText for the deterministic stub text + optional bodySource: "deterministic" discriminant on the chain entry. Zero renderer change; entries without bodySource keep current semantics. |
| D2 | Anti-regression cage: deterministic unit tests only, exhaustive over the ACs. No automated e2e harness (repo convention: bun test src/; e2e smoke stays manual per AGENTS.md). |
| D3 | Architecture: compressor-owned backfill. Branch messages threaded into compressEligible deps; new indexer method backfillChainRecords persists the index entry and issues refs, skipping dedup-canonical registration. Covered path untouched. |
| D4 | Design rule (now in AGENTS.core.md, all four repos): no new machinery if not essential. Every addition below reuses an existing field, type, or path plus a small discriminant. |
| D5 | Adjudicated at review, not pre-planned (see "Implementation deviations" below): fully-protected zero-coverage chains skip silently (no backfill-empty); extractChainRecords takes isIndexed only, protection is read from chain.protectedToolCallIds; buildDeterministicBody drops the unused chain param; backfillChainRecords takes an opts object (spill config + session identity + appendEntry), not a bare callback; resolveRange's entry param widens to a Pick<...> type (no behavior change). |
Architecture
compressEligible gains a second branch at the current no-summary skip.
CompressEligibleDeps grows: messages: any[] (the same array chain
detection ran on, i.e. the withClosingMessage(...) output - threading raw
branchMessages would lose the closing assistant on the message_end path with
rollingWindow: 0 and spuriously fail span resolution), diagnostics: Pick<DiagnosticSink, "report">, and backfill: { spillThreshold; spillPreviewBytes; sessionDir; sessionId } (the spill seam). Both call sites
(flushPending at index.ts:658-671 and /pruner compact at
index.ts:1035-1043) already hold all of these.
For an eligible zero-coverage chain:
- Resolve + extract - resolve the chain's positional span with the existing
resolveRange(src/chain-range-prune.ts) - same fail-closed duplicate- timestamp semantics as the drop path. As shipped,resolveRange's entry param widens toPick<ChainCompressionEntry, "startUserTimestamp" | "finalAssistantTimestamp">soextractChainRecordscan call it with aChainRangeinstead of a persisted chain entry - type-level only, behavior identical, regression-tested (see "Unchanged" below for scope). Walk the resolved span; for each middle tool call not protected (read fromchain.protectedToolCallIds, the same in-chain datadetectChainsalready populated - protected outputs are relocated verbatim at render time and must never be phase-1 stubbed, so they are never backfilled) and not already indexed (predicate: index membership, i.e. the indexer's privateindexmap has the occurrence key - NOTisSummarized(), which also covers dedup aliases and would strand the fully-deduped sub-case named in Problem), build aToolCallRecord:toolCallId,toolName,args,isErrorfrom the messages;resultText= full result text;timestamp= the toolResult message timestamp;resultTimestamplikewise (occurrence-key discriminant, via existingoccurrence-key.tshelpers);turnIndex=-1sentinel (backfilled records have no batch turn;context_tree_queryrendersTurn: -1- accepted cosmetic). Dedup-aliased members are re-indexed verbatim; their existing alias entries keep resolving unchanged. Extraction is a pure walk returning full-text records; spilling happens in step 2.
- Backfill (fail-closed, append-before-commit) -
indexer.backfillChainRecords(records, opts)(async), whereopts = { spillThreshold, spillPreviewBytes, sessionDir, sessionId, appendEntry }:- spill oversized results via the shared spill helpers (
blobPathFor/applySpill), a fail-closed variant of the eagerspillOversizedBatchpath (record getsspillPath/spillBytes/resultPreview/contentHash,resultTextemptied) - a ~935k-char member must not be inlined into the session JSONL; - allocate
t<N>refs via the existingallocateSummaryRefs/buildShortToolCallRefs(monotonicnextShortAliasNumber); - persist one
context-prune-indexentry carrying the records, the allocatedSummaryToolCallRef[], andbackfilled: true- refs ride the index entry so they are durable the moment the records are (atomic); - only after the append succeeds, commit in-memory state: records into
the
indexmap, aliases viaregisterSummaryRefs. A failed append leaves records and alias maps unchanged (burned alias numbers are acceptable -t<N>is opaque and monotonic, gaps are harmless); - never touch
contentHashToOriginal. Any throw -> keep the existingno-summaryskip; retried next flush.
- spill oversized results via the shared spill helpers (
- Compose -
buildDeterministicBody(records, refs): call count, tool-name histogram, span duration, first/last call name + args excerpt capped at 200 chars, and the refs. Zero LLM calls. On a retry where some/all members are already indexed (prior partial failure), refs for indexed members are recovered via the existinggetToolRefsForToolCallIdsand the records via the index - the body is composed over the full chain, not just newly extracted records. - Persist - chain entry as today, plus
rangeSummaryText(the deterministic body) andbodySource: "deterministic".
Retry discriminator (partial-failure convergence): zero extractable records
AND zero chain members present in the index -> genuine span mismatch ->
fail-closed skip + backfill-empty diagnostic. Zero newly extracted records but
members ARE in the index (prior index append succeeded, chain append failed) ->
proceed to compose + persist the chain entry using the already-durable records
and refs. The same observable "nothing new to extract" state never routes to a
permanent skip when recovery data exists.
Fully-protected exception to the discriminator (D5, adjudicated at review):
if the zero-extractable/zero-indexed state above is instead explained by every
middle call being protected (chain.middleToolCallIds non-empty and all present
in chain.protectedToolCallIds), the chain still takes the plain no-summary
skip but without the backfill-empty diagnostic. Compressing such a chain
saves zero tokens - every output would be relocated verbatim into the synthetic
body at render time regardless - and reporting backfill-empty for it would
misreport a healthy, fully-protected span as a mismatch, re-firing on every
restart for protected-heavy configs. This guard runs first; backfill-empty
only fires for the remaining genuine-mismatch case.
Covered chains take the existing branch untouched - byte-identical by construction.
Components
src/chain-compressor.ts
CompressEligibleDepsgainsmessages,diagnostics, and the spill seam (see Architecture).- New pure helper
extractChainRecords(messages, chain, isIndexed): ToolCallRecord[]-resolveRange-based span walk; no separate protection predicate param, protection is read fromchain.protectedToolCallIds(in-chain data already populated bydetectChains). - New pure helper
buildDeterministicBody(records, refs): string- nochainparam (unused by the body grammar). CompressEligibleResultunchanged (no new counters - cut as non-essential per D4;bodySourceon the persisted entries already distinguishes deterministic compressions for offline inspection).
src/indexer.ts
backfillChainRecords(records, opts): Promise<SummaryToolCallRef[]>whereopts = { spillThreshold, spillPreviewBytes, sessionDir, sessionId, appendEntry }- async; spill, allocate refs, append the marked index entry, then commit maps (order per Architecture step 2).ToolCallIndexerowns no appender today, so the callback rides the options object alongside the spill config and session identity it also needs (mirrors what theturn_endeager-spill path already assembles forspillOversizedBatch,index.ts:900).reconstructFromSession: index entries withbackfilled: true-> records restored into theindexmap, refs re-registered viaregisterSummaryRefs, dedup-canonical registration (contentHashToOriginal) skipped. Rationale: alias reconstruction today happens only fromCUSTOM_TYPE_SUMMARYentries (src/indexer.ts:95-97); backfilled chains have no summary message, so the backfilled index entry is the durable ref carrier.
src/types.ts
ChainCompressionEntrygainsbodySource?: "deterministic".- Index entry data gains
backfilled?: trueand optionalrefs?: SummaryToolCallRef[](present only on backfilled entries). DiagnosticKindunion gains"backfill-empty"(closed 3-member union today,src/types.ts:97).- All optional -> pre-existing entries load unchanged.
src/diagnostics.ts / src/commands.ts
DiagnosticSink.countersliteral gains the new kind.pruneStatusText(src/commands.ts:70-77) hardcodes the three existing kinds; gainsbackfill-empty(widget suffix, e.g.diag ... b<N>)./pruner compactreport text unchanged (deterministic-vs-fused distinction cut as non-essential; the persistedbodySourcefield carries that information).
index.ts
- Threads
messages(thewithClosingMessagearray),diagnostics, and the spill seam into bothcompressEligiblecall sites. context-prune-flush-metrics: unchanged (new counter cut as non-essential).
src/chain-range-prune.ts
resolveRange's entry param widens fromChainCompressionEntrytoPick<ChainCompressionEntry, "startUserTimestamp" | "finalAssistantTimestamp">soextractChainRecords(which walks aChainRange, not a persisted entry) can call it directly. Type-level only; behavior identical, regression-tested. Renderer behavior otherwise unchanged: still prefersentry.rangeSummaryText(src/pruner.ts:154); protected-output relocation is still live-by-id, coverage-independent.
Unchanged
src/pruner.ts, src/summarizer.ts, src/block-refs.ts (b<N> issuance
identical for both branches), CompressEligibleResult, flush-metrics entry
shape, compact report text.
Data flow
Live flush (automatic - no command required). flushPending finishes
per-batch summarization -> detectChains -> compressEligible. Zero-coverage
chains take resolve/extract -> backfill -> compose -> persist. Ordering: index
entry (with refs) before chain entry. If the chain-entry persist fails, records
AND refs are already durable; the next flush retries and the retry discriminator
routes it to compose-from-index, converging without duplicate index entries.
Known limitation (stated, not fixed here): flushPending returns early when
there are no pending batches, before chain detection. A stranded chain in an
otherwise idle session is therefore healed on the next flush that has any work,
or immediately by /pruner compact - not by /pruner now on an empty queue.
Acceptable: the motivating incident occurred in an actively flushing session.
Render. Unchanged: pruneMessages resolves ranges positionally and prefers
entry.rangeSummaryText. The deterministic body renders inside the same
<compressed-chain id="bN" tools="..."> envelope with working t<N> refs.
Recovery. context_tree_query("tN") -> alias map -> occurrence key ->
backfilled record (or its spill file) -> raw body. Identical to summary-issued
refs.
Restart. reconstructFromSession replays entries in order: backfilled index
entries restore records + aliases (dedup skipped), chain entries restore the
registry. Post-restart behavior is identical to pre-restart, including the
partial-failure retry (records + refs reconstruct from the index entry alone).
/pruner compact. Same compressEligible call - all uncovered chains handled
in one pass, no cap (no LLM cost to bound), zero summarizer traffic.
Error handling and edge cases
- Backfill throws (spill I/O, append failure, malformed span) -> local catch,
chain skipped with
reason: "no-summary"(fail-closed). Append-before-commit guarantees in-memory maps are untouched on failure, so phase-1 stub-replace cannot fire for records whose backfill never became durable. Retried next flush. - Index entry persisted, chain entry persist fails -> records + refs durable; retry discriminator (see Architecture) composes from the index. Converges, no duplicate index entries, no re-stranding.
- Genuine span mismatch (zero extractable records AND zero members indexed,
and NOT explained by full protection - see next bullet) -> fail-closed skip +
DiagnosticSinkreport, kindbackfill-empty, deduped per chain key. - Fully-protected uncovered chain (D5) -> zero extractable records and zero
members indexed, but every middle call is in
chain.protectedToolCallIds-> fail-closed skip, same as a genuine mismatch, but nobackfill-emptydiagnostic. Compressing would save zero tokens (everything is relocated verbatim at render regardless), and reporting a mismatch here would misreport a healthy span, re-firing every restart for protected-heavy configs. - Oversized member results -> spilled via the shared spill helpers
(
blobPathFor/applySpill), a fail-closed variant of the eagerspillOversizedBatchpath; the index entry carriesspillPath/resultPreview, never the full body. - Empty args/results -> body non-empty by construction (count / histogram / duration always present); excerpts capped at 200 chars.
- Protected outputs inside an uncovered chain -> excluded from backfill
entirely (never indexed -> never phase-1 stubbed, even in the chain-entry
failure window); at render time
chain-range-prune.tsrelocates them verbatim by bare id within the resolved range, unchanged. If protection covers every middle call in the chain, the chain hits the fully-protected exception above instead ofbackfill-empty. - Dedup - backfilled records never become
contentHashToOriginalcanonicals (live and reconstruction paths both honorbackfilled: true). A later identical output finds no canonical and is indexed normally. Fully-deduped chains ARE backfilled (index-membership filter, notisSummarized); their pre-existing alias entries keep resolving. isSummarized()is true for backfilled occurrences once the index append commits: phase-1 stub-replace may fire even if the chain entry append later fails - acceptable, the raw content is durable and recoverable.- Legacy entries (no
bodySource/backfilled/refs) -> absence preserves today's semantics exactly. - Cache-prefix impact - compressing a chain rewrites in-flight history from
that point, invalidating the provider prompt-cache suffix once. Frequency
is unchanged: eligibility timing is owned by the existing rolling-window logic;
this fix only removes the terminal skip, so marginal invalidations = one per
previously-stranded chain. The deterministic body is render-stable (no
now(), no LLM nondeterminism), so post-compression renders are byte-identical and cache-friendly. Retry convergence cannot thrash: retries run only while the chain entry never persisted (nothing was rewritten yet); once it lands the output is frozen. Economics: a stranded ~300k-token chain costs a cache-read (~0.1x input price) on every call forever; the one-time suffix re-write (~1.25x) breaks even within ~1-2 calls. Same trade every existing prune (phase-1 stub replacement) already makes - no new invalidation class.
Out of scope (follow-up tickets per the issue's roast)
- Coverage-ratio gate for
hasPerBatchSummaryCoveringAny(any-overlap today: a 639-call chain with 1 covered call takes the covered path). - Capture-layer anomaly root cause (why an 800-turn agent-message cycle produced no batch).
- Partial-coverage handling: chains with any coverage take the existing covered path, unchanged.
- Chain-only compression on an empty flush (see Known limitation).
Testing approach
Deterministic unit cage (D2), all through pure seams (compressEligible with
stubbed deps, indexer directly). bun test src/ green is an AC.
New behavior (src/chain-compressor.test.ts, src/indexer.test.ts):
- zero-coverage chain -> compressed; body contains count, histogram, duration,
refs;
fuseRangespy never called (zero-LLM assertion) backfillChainRecords: records queryable by raw id andt<N>ref;contentHashToOriginaluntouched; index entry carriesbackfilled: true+refs- append failure -> in-memory index and alias maps unchanged, no chain entry,
no-summaryskip (fail-closed transactionality) - chain-append-failure -> restart -> retry: records + refs reconstruct from the backfilled index entry; retry composes and persists the chain entry; no duplicate index entries (convergence test)
- retry discriminator: members-indexed state composes; zero-extracted +
zero-indexed state skips with
backfill-emptydiagnostic - fully-deduped chain -> backfilled (index-membership filter), compressed, alias entries still resolve
- protected member -> excluded from backfill records and refs; relocated verbatim at render
- oversized member -> spilled (
spillPath/resultPreview/contentHashset,resultTextempty, full body absent from the persisted entry) - empty-args chain -> non-empty body; 200-char excerpt cap enforced
- backfilled record renders
Turn: -1incontext_tree_queryoutput (pinned cosmetic)
Anti-regression cage:
- covered-path byte-identity: chain with coverage -> entry deep-equals pre-change
snapshot;
bodySourceunset; backfill spy never invoked (the existing "skips chain with no summary" test atsrc/chain-compressor.test.ts:129-140becomes the deterministic-compression test; the covered path gets its own identity test) - legacy round-trip: real pre-change
context-prune-chainandcontext-prune-indexJSONL fixtures reconstruct to identical state - restart: write entries ->
reconstructFromSession-> refs resolve, dedup canonicals exclude backfilled records - integration (
src/range-compression.integration.test.ts, existing file): one covered + one uncovered chain in a single flush -> both compressed, covered output unchanged,expectNoOrphanToolResultsholds - multi-chain compact: three uncovered chains, one
compressEligiblecall -> three deterministic entries
Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: PRUNING.md (deterministic-fallback subsection
in the algorithm + rationale; diagnostics table gains
backfill-empty), README.md (compact behavior note + empty-flush limitation), AGENTS.md (context-prune-chainrow:bodySource;context-prune-indexrow:backfilled+refs; diagnostics row:backfill-empty) - Derived / memory docs invalidated: none
Acceptance criteria (from the issue, verbatim intent)
- Covered chains: output byte-identical to current behavior; new unit test asserts the deterministic path is not taken when coverage exists.
- An eligible zero-coverage chain is compressed with a deterministic body:
call count, tool histogram, span duration, working
t<N>refs; zero LLM calls asserted. context_tree_queryreturns the raw output for a tool call inside a backfilled compressed range.- Backfill failure -> chain remains uncompressed with
no-summaryskip; partial failure (index persisted, chain entry not) converges on retry instead of re-stranding. - Backfilled index records are excluded from content-hash dedup canonical lookup.
- Synthetic body renders non-empty even for a chain whose calls have empty args/results.
session_startreconstruction round-trips the new entry fields (including refs on backfilled index entries); pre-existing entries without them load unchanged./pruner compacthandles multiple uncovered chains in one invocation.- PRUNING.md and README updated;
bun test src/green.