Severity: P0 — unbounded model-burning runaway. Affects LOCAL (lms) AND CLOUD deployments (operator-confirmed 2026-06-23). Release classification: boardless — agent-OS REM/model-reliability P0.
Self-assigned: @neo-opus-grace. Sub of REM epic #12065.
TL;DR
REM sleep tri-vector graph extraction sends the chat model (gemma-4-26b, ctx 131,072) a prompt that exceeds its context window, the failure is mis-classified so the re-serve bound never fires, and the session is re-served every cycle forever — an unbounded loop that pins the model (429 + prompt-cache thrash) and never digests anything. One root — a bytes/4 token-estimate that under-counts dense content — compounds three ways. Confirmed live (local): gemma context climbed 140,173 → 155,162 → 166,070 tok while undigested stayed pinned at 167 and recentCycles: []. The same path + same model cap run in cloud → same runaway.
Why this affects cloud too (not just local)
The runaway lives in deployment-neutral code — DreamService (orchestrator), SemanticGraphExtractor + consumerFrictionHelper + sessionChunker (graph/memory-core services) — driven against the same localModels.chat.contextLimitTokens (131072) and the same bytes/4 estimate. In cloud the chat model is a separate container; a large/dense undigested session triggers the identical send→overflow→mis-classify→re-serve loop and pins the cloud model container. This is the "model container burning" symptom from the start of this thread, root-caused.
The single root, compounding three ways
The pre-check token estimate is Math.ceil(bytes / 4) — a flat 4-bytes-per-token heuristic (consumerFrictionHelper.mjs:141 bytesToTokens, BYTES_PER_TOKEN_HEURISTIC = 4; mirrored by sessionChunker.mjs:22 DEFAULT_TOKENS_PER_CHAR = 0.25 and DreamService.mjs:53 estimatePayloadTokens). Real session content — code, JSON, logs, the tri-vector JSON schema — tokenizes far denser (~2.4–2.85 bytes/token in this incident), so the estimate under-counts ~30–40%. Consequences:
Entry-guard bypass → overflow. invokeWithGuardrail's Angle-2 pre-check (consumerFrictionHelper.mjs:350-375) skips any payload whose estimated tokens exceed safeProcessingLimitTokens (100,000). The under-count puts an actually-~140k+ session under 100k → it is sent → overflows the 131,072 window → silent empty / context-overflow → null.
Retry-append amplification → "still getting more." On a non-validating response the retry loop (SemanticGraphExtractor.mjs:280-284, maxRetries=3 at :143) does messages.push(assistant: result.content) + messages.push(user: "…failed validation, correct…") and retries. Near the limit the model returns a truncated, non-empty response → fails validation → appended → next prompt larger (140k→155k→166k). The only guard against this — the silent-overflow abort at :252-268 — fires only on a clean-empty response; a truncated-non-empty one bypasses it and feeds the loop.
Re-serve-bound bypass → never terminates. DreamService.mjs:51 PERMANENT_DEFER_REASONS = new Set(['skip-over-band']). A session stops being re-served (digestState='deferred') only when deferReason === 'skip-over-band' (set at :555 only when payloadSizeTokens > safeBandTokens, via the same under-counting estimate) AND digestAttempts >= maxDigestAttempts (:560). Because the under-count puts the session under the band, deferReason becomes under-band-choke — which :43-48 explicitly treats as non-terminal ("a guess… they stay undigested, re-served"). So the over-context session is re-served every REM cycle indefinitely — never deferred, never digested. (Per-extraction is bounded by maxRetries; the across-cycle re-serve is the unbounded part — confirmed: undigested pinned at 167, recentCycles: [].)
The Architectural Reality (file:line)
ai/services/memory-core/helpers/consumerFrictionHelper.mjs:141 — bytesToTokens = Math.ceil(bytes / BYTES_PER_TOKEN_HEURISTIC), BYTES_PER_TOKEN_HEURISTIC = 4. :350-375 — the skip pre-check.
ai/services/graph/SemanticGraphExtractor.mjs:143,221 — maxRetries=3 loop; :252-268 — clean-empty-only overflow abort; :280-284 — the monotonic append. It is single-pass (does not call sessionChunker).
ai/services/graph/sessionChunker.mjs:22 — DEFAULT_TOKENS_PER_CHAR = 0.25 (same heuristic family; wired into TopologyInferenceEngine, not tri-vector extraction).
ai/daemons/orchestrator/services/DreamService.mjs:51 — PERMANENT_DEFER_REASONS; :53 — estimatePayloadTokens; :553-560 — deferReason / digestState classification.
ai/config.template.mjs:239-240 — contextLimitTokens=131072, safeProcessingLimitTokens=100000.
The Fix (keystone + hardening)
Keystone — make the token estimate not under-count (fixes all three at once). An accurate/conservative estimate makes the over-context session classify as skip-over-band, which (a) skips it before the send (no overflow, no amplification) and (b) is in PERMANENT_DEFER_REASONS → deferred after maxDigestAttempts → re-serve stops → termination. Design (decide on the PR, cross-family — the estimate is shared by every model-consumer, so the change has blast radius):
- Recommended immediate: a conservative bytes/token ratio (≈ 2.5–3, i.e. assume dense) for the skip decision — over-counting is safe (skip-before-overflow; over-band sessions then route to defer/chunk), under-counting is the bug. Apply consistently to
bytesToTokens, sessionChunker DEFAULT_TOKENS_PER_CHAR, and DreamService.estimatePayloadTokens.
- Hardening: use the provider's real tokenizer for the band pre-check where available; keep the ratio as the no-round-trip fallback. Invariant: a payload that passes the band stays under
contextLimitTokens at worst observed density, with margin for the prompt wrapper + output.
Hardening — size-aware retry. In SemanticGraphExtractor, before the repair-append (:280) check the running messages size against the band and abort instead of append when approaching it; treat a truncated / finish_reason === 'length' response as overflow (close the empty-only-abort hole at :252-268).
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
bytesToTokens / BYTES_PER_TOKEN_HEURISTIC (consumerFrictionHelper.mjs) |
this incident + ctx cap 131072 |
estimate does not under-count dense content; over-band payloads skip before send |
emit size-precheck-skip |
JSDoc |
L2: dense ~140k payload estimates > band; incident-shape regression |
DEFAULT_TOKENS_PER_CHAR (sessionChunker.mjs) + estimatePayloadTokens (DreamService.mjs) |
same |
same conservative ratio so chunk bounds + defer-classification agree |
n/a |
JSDoc |
L2 chunk-bound + defer-classification tests |
SemanticGraphExtractor retry loop |
the amplification |
size-aware abort; truncated/length treated as overflow, not repaired |
abort, no append |
code comment |
L2: near-band response does not grow the prompt |
DreamService deferReason → digestState |
re-serve bound (#13845) |
an over-context session classifies skip-over-band → deferred after maxDigestAttempts → re-serve stops |
stays undigested only if genuinely under-band |
JSDoc |
L2: over-context session terminates re-serve |
Acceptance Criteria
Out of Scope (complementary, already ticketed)
- #12073 (chunking-aware Tri-Vector) — once over-band sessions defer correctly, chunking is what actually digests them (chunk → each under band). This ticket makes the band reliable; #12073 makes deferred sessions recoverable.
- #13835 / #13845 (stop-re-serve kernel) — exists; this fix makes its
skip-over-band gate actually trigger.
- #13851 (loaded-context verification, 4096 vs 131072) — a distinct root.
- #13914 (deployment observability) — would have surfaced this remotely; today it's a black box.
Avoided Traps
- Don't raise the model context — the cap is correct; the estimate is wrong.
- Don't rely on the post-invocation silent-overflow detector — it fires after the burn and only on clean-empty.
- Don't fix only the entry guard — without the size-aware retry + correct classification, amplification + unbounded re-serve persist.
- Don't split the estimate — guard, chunker, and defer-classification must agree, or the bug reappears in one path.
Related
Parent epic #12065. Complements #12073, #13835, #13845, #13851, #13914. Live-incident anchor: this session.
Origin Session ID: acbc412a-8060-422c-b0dc-05f929da50e8
Handoff Retrieval Hints: bytes/4 token under-count REM context overflow unbounded re-serve skip-over-band under-band-choke; consumerFrictionHelper bytesToTokens BYTES_PER_TOKEN_HEURISTIC DreamService PERMANENT_DEFER_REASONS SemanticGraphExtractor retry append
Severity: P0 — unbounded model-burning runaway. Affects LOCAL (lms) AND CLOUD deployments (operator-confirmed 2026-06-23).
Release classification: boardless — agent-OS REM/model-reliability P0.Self-assigned: @neo-opus-grace. Sub of REM epic #12065.
TL;DR
REM sleep tri-vector graph extraction sends the chat model (gemma-4-26b, ctx 131,072) a prompt that exceeds its context window, the failure is mis-classified so the re-serve bound never fires, and the session is re-served every cycle forever — an unbounded loop that pins the model (429 + prompt-cache thrash) and never digests anything. One root — a
bytes/4token-estimate that under-counts dense content — compounds three ways. Confirmed live (local): gemma context climbed 140,173 → 155,162 → 166,070 tok whileundigestedstayed pinned at 167 andrecentCycles: []. The same path + same model cap run in cloud → same runaway.Why this affects cloud too (not just local)
The runaway lives in deployment-neutral code —
DreamService(orchestrator),SemanticGraphExtractor+consumerFrictionHelper+sessionChunker(graph/memory-core services) — driven against the samelocalModels.chat.contextLimitTokens(131072) and the samebytes/4estimate. In cloud the chat model is a separate container; a large/dense undigested session triggers the identical send→overflow→mis-classify→re-serve loop and pins the cloud model container. This is the "model container burning" symptom from the start of this thread, root-caused.The single root, compounding three ways
The pre-check token estimate is
Math.ceil(bytes / 4)— a flat 4-bytes-per-token heuristic (consumerFrictionHelper.mjs:141bytesToTokens,BYTES_PER_TOKEN_HEURISTIC = 4; mirrored bysessionChunker.mjs:22DEFAULT_TOKENS_PER_CHAR = 0.25andDreamService.mjs:53estimatePayloadTokens). Real session content — code, JSON, logs, the tri-vector JSON schema — tokenizes far denser (~2.4–2.85 bytes/token in this incident), so the estimate under-counts ~30–40%. Consequences:Entry-guard bypass → overflow.
invokeWithGuardrail's Angle-2 pre-check (consumerFrictionHelper.mjs:350-375) skips any payload whose estimated tokens exceedsafeProcessingLimitTokens(100,000). The under-count puts an actually-~140k+ session under 100k → it is sent → overflows the 131,072 window → silent empty /context-overflow→null.Retry-append amplification → "still getting more." On a non-validating response the retry loop (
SemanticGraphExtractor.mjs:280-284,maxRetries=3at:143) doesmessages.push(assistant: result.content)+messages.push(user: "…failed validation, correct…")and retries. Near the limit the model returns a truncated, non-empty response → fails validation → appended → next prompt larger (140k→155k→166k). The only guard against this — the silent-overflow abort at:252-268— fires only on a clean-empty response; a truncated-non-empty one bypasses it and feeds the loop.Re-serve-bound bypass → never terminates.
DreamService.mjs:51PERMANENT_DEFER_REASONS = new Set(['skip-over-band']). A session stops being re-served (digestState='deferred') only whendeferReason === 'skip-over-band'(set at:555only whenpayloadSizeTokens > safeBandTokens, via the same under-counting estimate) ANDdigestAttempts >= maxDigestAttempts(:560). Because the under-count puts the session under the band,deferReasonbecomesunder-band-choke— which:43-48explicitly treats as non-terminal ("a guess… they stayundigested, re-served"). So the over-context session is re-served every REM cycle indefinitely — never deferred, never digested. (Per-extraction is bounded bymaxRetries; the across-cycle re-serve is the unbounded part — confirmed:undigestedpinned at 167,recentCycles: [].)The Architectural Reality (file:line)
ai/services/memory-core/helpers/consumerFrictionHelper.mjs:141—bytesToTokens = Math.ceil(bytes / BYTES_PER_TOKEN_HEURISTIC),BYTES_PER_TOKEN_HEURISTIC = 4.:350-375— the skip pre-check.ai/services/graph/SemanticGraphExtractor.mjs:143,221—maxRetries=3loop;:252-268— clean-empty-only overflow abort;:280-284— the monotonic append. It is single-pass (does not callsessionChunker).ai/services/graph/sessionChunker.mjs:22—DEFAULT_TOKENS_PER_CHAR = 0.25(same heuristic family; wired intoTopologyInferenceEngine, not tri-vector extraction).ai/daemons/orchestrator/services/DreamService.mjs:51—PERMANENT_DEFER_REASONS;:53—estimatePayloadTokens;:553-560—deferReason/digestStateclassification.ai/config.template.mjs:239-240—contextLimitTokens=131072,safeProcessingLimitTokens=100000.The Fix (keystone + hardening)
Keystone — make the token estimate not under-count (fixes all three at once). An accurate/conservative estimate makes the over-context session classify as
skip-over-band, which (a) skips it before the send (no overflow, no amplification) and (b) is inPERMANENT_DEFER_REASONS→deferredaftermaxDigestAttempts→ re-serve stops → termination. Design (decide on the PR, cross-family — the estimate is shared by every model-consumer, so the change has blast radius):bytesToTokens,sessionChunkerDEFAULT_TOKENS_PER_CHAR, andDreamService.estimatePayloadTokens.contextLimitTokensat worst observed density, with margin for the prompt wrapper + output.Hardening — size-aware retry. In
SemanticGraphExtractor, before the repair-append (:280) check the runningmessagessize against the band and abort instead of append when approaching it; treat a truncated /finish_reason === 'length'response as overflow (close the empty-only-abort hole at:252-268).Contract Ledger Matrix
bytesToTokens/BYTES_PER_TOKEN_HEURISTIC(consumerFrictionHelper.mjs)size-precheck-skipDEFAULT_TOKENS_PER_CHAR(sessionChunker.mjs) +estimatePayloadTokens(DreamService.mjs)SemanticGraphExtractorretry looplengthtreated as overflow, not repairedDreamServicedeferReason → digestStateskip-over-band→deferredaftermaxDigestAttempts→ re-serve stopsAcceptance Criteria
deferReason='skip-over-band'and is markeddeferredaftermaxDigestAttempts— re-serve terminates (no unbounded loop).finish_reason==='length'response aborts rather than repair-retries.contextLimitTokensat worst observed density + wrapper/output margin.consumerFrictionHelper,sessionChunker,DreamService(no split-brain estimate).Out of Scope (complementary, already ticketed)
skip-over-bandgate actually trigger.Avoided Traps
Related
Parent epic #12065. Complements #12073, #13835, #13845, #13851, #13914. Live-incident anchor: this session.
Origin Session ID: acbc412a-8060-422c-b0dc-05f929da50e8
Handoff Retrieval Hints:
bytes/4 token under-count REM context overflow unbounded re-serve skip-over-band under-band-choke;consumerFrictionHelper bytesToTokens BYTES_PER_TOKEN_HEURISTIC DreamService PERMANENT_DEFER_REASONS SemanticGraphExtractor retry append