Skip to content

Rust mode: a compaction marker placed on a synthetic user row wedges the session (coverage gap on every HARD pass, raw fallback refuses) #561

Closed

Description

@iceteaSA

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.

  1. Another plugin injects a notification as a user message whose only part is text with synthetic: true. encodeOpenCodeMessagesToCk sets meta.synthetic when every part is synthetic (module-wire.ts:893-901), so the row is synthetic on the wire.
  2. The next historian chunk starts right at that row. build_historian_chunk skips 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's live array excludes synthetic blocks (transform.rs:3528-3532), so nothing complains yet.
  3. 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. injectCompactionMarker adds {"type":"compaction","auto":true} to it, with no synthetic flag (compaction-marker.ts:498-514).
  4. Row N now has a part that isn't synthetic, so meta.synthetic flips to false. Its block joins live, and OpenCode's filterCompacted starts the input at that row. The next HARD pass runs first_uncovered_live_block (transform.rs:8527-8542), finds N at or below the coverage end with no compartment, and returns CoverageGap (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 compaction parts from content (module-wire.ts:996) and from block mappings (:831). If they also didn't count in the every(synthetic) check, the marker would stop flipping rows. Marking the injected part synthetic: true would do the same from the other side.
  • Don't pick a boundary outside coverage. findBoundaryUserMessage could 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 compaction parts as zero tokens in hasCountableParts. They produce no wire bytes.
  • Print trusted in the raw_fallback_over_context_limit line.

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

  1. magic-alfonso commented on Sep 29, 2026

    @magic-alfonso

    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). findBoundaryUserMessage has 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:

    • a compaction part will no longer change a row's synthetic classification, which also heals sessions already in this state;
    • the marker drain will not pick a synthetic-only user row as its boundary;
    • the raw_fallback_over_context_limit line 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.

  2. magic-alfonso commented on Sep 29, 2026

    @magic-alfonso

    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-mc module 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 run opencode plugin update). If you still see this on 0.44.2, please reopen with a log.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions