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

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

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