Repository navigation
Rust mode: a compaction marker placed on a synthetic user row wedges the session (coverage gap on every HARD pass, raw fallback refuses) #561
Description
Version: v0.44.1, OpenCode 1, transform_mode: "rust". The code paths below are unchanged on master (edd6529).
What happens
Every pass fails with:
rust transform failed; attempting LKG replay: coverage gap: live item <mid>#0 (ordinal N) sits at or below coverage end Some(M) but no compartment covers it; composing m0 would silently drop it from the tail
LKG replay misses, then the raw fallback refuses:
raw_fallback_over_context_limit estimated=292920 proxy_bytes=592322 proxy_tokens=148081 limit=995904
rust pass: decision=error reason=transform_failed served_from=raw applied=false
The user sees the ENGINE_RECONNECTING refusal on every turn. /ctx-flush and restarting OpenCode don't help. The session can't recover on its own: a failed HARD pass commits nothing, so the fold and marker drain that would move the boundary past the row never run.
How it gets there
Reconstructed from plugin logs and the module store rows; each step matches the code at v0.44.1.
- Another plugin injects a notification as a user message whose only part is text with
synthetic: true.encodeOpenCodeMessagesToCksetsmeta.syntheticwhen every part is synthetic (module-wire.ts:893-901), so the row is synthetic on the wire. - The next historian chunk starts right at that row.
build_historian_chunkskips synthetic messages (historian_chunk.rs:500-529), so the new compartment starts at N+1. Store-side validation allows the sparse gap by design (compartment_coverage.rs,resolve_coverage), and the transform'slivearray excludes synthetic blocks (transform.rs:3528-3532), so nothing complains yet. - About half a minute later the compaction-marker drain targets that compartment's end.
findBoundaryUserMessage(compaction-marker.ts:263-302) returns the latest user row at or before the target and has no synthetic check, so it returns row N.injectCompactionMarkeradds{"type":"compaction","auto":true}to it, with no synthetic flag (compaction-marker.ts:498-514). - Row N now has a part that isn't synthetic, so
meta.syntheticflips to false. Its block joinslive, and OpenCode'sfilterCompactedstarts the input at that row. The next HARD pass runsfirst_uncovered_live_block(transform.rs:8527-8542), finds N at or below the coverage end with no compartment, and returnsCoverageGap(transform.rs:5076-5093).
SOFT and defer passes don't run the guard, so the failure waits for the next HARD pass. For us that was the first cold pass after a restart, about 30 minutes after the marker landed. In the module store there was exactly one uncovered ordinal in the session's covered range, and it was the row carrying the marker.
The root problem is that a row's synthetic classification isn't stable. The historian decides once, and then the marker drain changes the input that decision was made on.
Why the raw fallback refuses
Both numbers in the refusal line are under the limit. The refusal comes from trust. estimateFinalWireInputTokens is only trusted when every input part is countable (final-wire-token-estimate.ts:204-210), and hasCountableParts has no compaction case (:235-292). serveRawFallback treats an untrusted estimate as Infinity (rust-mode-transform.ts:2536-2548).
The raw input always starts at the marker's boundary user row, and that row always has the compaction part. So the raw fallback is refused on any session that carries a Magic Context compaction marker, whether or not this gap exists. A direct check with a measured tool set:
| input message parts | trusted |
|---|---|
text |
true |
synthetic text |
true |
compaction + synthetic text |
false |
The refusal log doesn't print trusted, so the line looks like an arithmetic bug.
Suggested fixes
- Keep the classification stable. The encoder already drops
compactionparts from content (module-wire.ts:996) and from block mappings (:831). If they also didn't count in theevery(synthetic)check, the marker would stop flipping rows. Marking the injected partsynthetic: truewould do the same from the other side. - Don't pick a boundary outside coverage.
findBoundaryUserMessagecould skip all-synthetic user rows, or prefer a user row inside a published compartment range. - Or let historian compartments start at the requested start ordinal, so a skipped synthetic head row is still inside the range. OpenCode ordinals don't need the sparse-gap allowance that the Claude Code proxy does.
- Count
compactionparts as zero tokens inhasCountableParts. They produce no wire bytes. - Print
trustedin theraw_fallback_over_context_limitline.
A regression test for the first three: put a synthetic-only user row directly after a compartment end, publish the next compartment, drain the marker onto that row, then run a HARD pass. It should compose.
Workaround
Extend the compartment after the gap so it starts at the uncovered ordinal. That means changing start_message and start_message_id (<mid>#0) on that one mc_compartments row. Nothing overlaps, because the gap sits between two otherwise contiguous compartments. The row's content is a synthetic notice, so the summary loses nothing that matters.
We replayed the session's real input through the module's transform handler against copies of the store. The unedited copy reproduced the coverage-gap error. The edited copy completed a HARD pass, folded the next compartment, and served the tail starting right after the new coverage end. A second pass deferred with byte-identical m0. On a third, unedited copy, dropping only the compaction part from that row's input (so the row is synthetic again) also let the HARD pass compose, which isolates the marker as the trigger.
Activity
magic-alfonso commentedon Sep 29, 2026
Thank you, this is an excellent report. The replay against store copies, with the unedited, edited and marker-stripped arms, isolates the trigger cleanly.
I've checked the two central claims at source on master. The encoder marks a row synthetic only when every part is synthetic, and the compaction part isn't (
module-wire.ts:893-901).findBoundaryUserMessagehas no synthetic check (compaction-marker.ts:263-302). So yes: the marker drain can land on the synthetic row the historian skipped, flip its classification, and leave a one-ordinal gap that the HARD-pass coverage guard then refuses on every pass.We're fixing it along the lines you suggested:
compactionpart will no longer change a row's synthetic classification, which also heals sessions already in this state;raw_fallback_over_context_limitline will print whether the estimate was trusted.One part we're handling carefully: some sessions have already served that row as non-synthetic. Flipping it back must not change bytes the provider has cached, or it could trigger the Anthropic thinking-signature 400 on newer models, so the fix includes a byte-identity check for that case.
We're deliberately not making the raw fallback trust compaction parts. Magic Context shouldn't serve an unmanaged array while it manages compaction, so the refusal is the intended behaviour. Its log line was the misleading part.
Your workaround (extending the next compartment's start to cover the gap ordinal) is sound and safe to use until the fix ships. I'll update here when it's in a release.
magic-alfonso commentedon Sep 29, 2026
Fixed in 0.44.2, now on npm. A compaction marker no longer changes how a synthetic user message (such as another plugin's notice) is classified, and it's never placed on one, so full passes no longer fail with a coverage gap. Sessions already stuck recover on their next full pass. In Rust mode the
ck-mcmodule also needs to be the current build.To update: OpenCode 1 and Pi pick it up on restart; on OpenCode 2 open
/plugins, select Magic Context and press ctrl+u (or runopencode plugin update). If you still see this on 0.44.2, please reopen with a log.