Skip to content

Compaction boundary absent from the in-memory message list → prefix trim defers forever with no recovery; pressure escalation frozen on incremental event-payload usage #538

Description

@Sugaroverdose

A long session (v0.43.2, MC-managed mode) froze at 100% context after an
app restart + session resume. The historian was healthy (pointer
advanced to the tail, zero failed runs); the problem was entirely in
two MC-internal paths. Timestamps UTC; session id redacted.
1. Dangling boundary → silent infinite defer (prefix-trim path).
The session's compaction boundary (a message id in session meta) was
not present in the in-memory "current messages" list after a session
resume — the resumed list held 58–115 of the session's ~574 messages,
and the boundary anchored a message outside that window in every app
instance across ~3 h (surviving multiple restarts). Log:

[09-25 23:46:02.224Z] [ses_…] transform stage: stage=findSessionId elapsed=0.3ms messages=58
[09-25 23:46:02.275Z] [ses_…] transform: first pass reset — percentage=45.3% — clearing stale usage state
[09-25 23:46:02.494Z] [ses_…] prefix trim: boundary msg_0a5836bd… absent from current messages; pass=priced; no in-pass trim applied
…94 consecutive passes, identical shape…
[09-26 02:24:48.635Z] [ses_…] prefix trim: boundary msg_0d5f1939… absent from current messages; pass=defer; no in-pass trim applied

Untrimmed, the wire carried the full raw history, which grew each turn
to the context limit (stored tokens.total: 158,958 → 160,268 →
165,394 → 169,812 → 262,145; limit 262,144). A fresh restart re-aligned
the in-memory window and the first pass trimmed normally:

[09-26 02:49:20.759Z] [ses_…] transform: final-wire telemetry estimate=160884 trusted=true conversation=59723 …
[09-26 02:50:06.672Z] [ses_…] event message.updated: totalInputTokens=80538 contextLimit=258978 percentage=31.1%

The code (v0.43.2 dist, index-g6hpfkqa.js):

// :35357-35366 — the prefix-trim path: a single failed lookup, refused, on every pass
} else {
  const index = findBoundaryIndex(options.sessionId, options.messages, boundary);
  if (index >= 0) {
    options.messages.splice(0, index + 1);
    status = "applied";
  } else {
    sessionLog(..., `prefix trim: boundary ${boundary} absent from current
      messages; pass=…; no in-pass trim applied`);
    status = "refused";           // silently terminal — no counter, no re-anchor
  }
}

Note: this is the same failure shape as #264 ("Degraded mode in
inject-compartments has no recovery path when boundary is outside the
visible window", fixed in v0.33.1) — but in a path the fix did not
reach. The sibling inject-compartments path in v0.43.2 ships the full
recovery ladder, in the same file:

// :33772-33799 — inject-compartments path (the #264 fix): counted, re-anchors, falls back
} else {
  const degradedCount = noteDegradedRebuild(sessionId);
  if (degradedCount === 1) reconcileForkOrphanedCompactionMarkers(db, sessionId);
  let reAnchored = false;
  if (degradedCount >= REANCHOR_MIN_DEGRADED_PASSES && isCacheBusting) {
    …
    logReanchorOnce(…`compartment injection re-anchored: natural boundary
      ${trimEndMessageId} not visible for ${degradedCount} passes; splicing
      at visible compartment boundary ${resultEndMessageId}`);
  }
  if (!reAnchored) {
    needsFreshMaterialization = true;
    logReanchorOnce(…`compartment injection degraded: … requesting fresh
      materialization to re-cut the baseline`);
  }
}

Suggested behavior: port that ladder to the prefix-trim path — a
failed lookup is a counted degraded state, not a terminal refusal.
Secondary hardening, same direction: make the cut total by using the
boundary's position in MC's own persistent order (the ordinal space
the historian reads — on the session above the pointer reached 497 of
574 while loaded windows held only 58–115) as the cut coordinate, so a
window that starts entirely after the boundary yields [summary] + [whole window] instead of "absent"; the boundary ID then serves as an
integrity check (no longer resolves → visible "boundary stale" +
re-compaction from list head). (The existing prefixTrimSourceOrder
position-cut mechanism is a close primitive, but it is armed only when
a deferred history refresh is pending and captures the live window as
its coordinate system — which, when the window starts after the
boundary, refuses just as silently.)
2. Pressure escalation runs on incremental event-payload usage, not
final cumulative usage.

The v0.43.2 pressure formula does include cache.write
(index.js:40923: input + cache.read + cache.write) — the undercount
is in the payload it reads. Verbatim, the final call of the frozen
window (same timestamp, two sources):

[09-26 02:29:10.414Z] [ses_…] event message.updated: … hasUsageTokens=true
                             tokens.input=2398 cache.read=165393 cache.write=0
[09-26 02:29:10.414Z] [ses_…] event message.updated: totalInputTokens=167791
                             contextLimit=214982 percentage=78.0%
[09-26 02:29:10.521Z] [ses_…] transform scheduler: percentage=78.0% inputTokens=167791 … decision=execute

versus the final stored usage for that same call (opencode store):
tokens.input=89,167, tokens.total=262,145 — the true prompt size,
≈100% of the 262,144 limit. The message.updated payload is an
incremental reading (new input this call + cache hits), while the
stored usage is the cumulative prompt; MC's pressure state is built
from the former (index.js:40980-40985 → contextUsageMap → scheduler
→ latch):

// index.js:40923-40934
const totalInputTokens = (info.tokens?.input ?? 0)
  + (info.tokens?.cache?.read ?? 0) + (info.tokens?.cache?.write ?? 0);
const pressureInputTokens = usageReadingValid ? totalInputTokens : 0;
// :40980-40985
const percentage = contextLimit > 0 ? pressureInputTokens / contextLimit * 100 : 0;
deps.contextUsageMap.set(info.sessionID, { usage: { percentage, inputTokens: pressureInputTokens }, … });

The emergency drain latch arms at max(85, threshold+2) percent
(index.js:39898) — at the true ~100% it should have armed and
bypassed the drain budget; at the frozen 78% it never did
(emergency_drain_active stayed 0 for the whole window). Compounding:
limit-resolution drift (the same session resolved 214,982, then
258,978, then 262,144 across passes) makes the percentage
pass-dependent.
Suggested behavior: build the pressure/escalation state from the
cumulative final usage of completed calls (the value the host stores
per message — tokens.total above), with the message.updated
streaming payload kept for live display only; the
terminalAssistantUpdate completion hook (index.js:40897) is the
natural point to fetch it.
This cannot cause spurious compaction: the cumulative total is the
ground-truth prompt size — the reading moves toward reality, never
above it (the non-cached portion persists as cache on the next turn,
so the cumulative total is a floor on next turn's pressure, not a
transient spike). And the denominator is already floored
(index.js:40978: contextLimit = max(contextLimit, provenSafeInputTokens)), so the limit cannot resolve below a
proven-safe input and manufacture a high-percentage reading; the
residual limit drift is in the under-escalation direction (safe). The
reading can only rise to the true value, and it fires only in the
regime that needs the rescue.

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