Skip to content

🤖 feat: add token-budget context window rollovers - #4097

Merged
ThomasK33 merged 97 commits into
mainfrom
plan-token-budget-combined
Sep 7, 2026
Merged

🤖 feat: add token-budget context window rollovers#4097
ThomasK33 merged 97 commits into
mainfrom
plan-token-budget-combined

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

Add an opt-in Token-budget context windows experiment that replaces automatic LLM summarization with hard context-window rollovers. Earlier messages remain in the transcript and on disk; agents recover details on demand through a bounded session_history tool and carry concise working notes through an optional extra memory entry while token-budget mode is active.

Implementation

  • Evaluate budget after settled tool steps and on send, then atomically persist a reset boundary, hidden lead-in, and triggering input. Preserve queue attribution and prevent duplicate rollovers after interruptions or append acknowledgment failures.
  • Warn before the rollover threshold and optionally append /memories/workspace/context-notes.md as a ninth entry while token-budget mode is active. Ordinary hot-memory selection and budgets stay unchanged; the extra has a separate bounded allowance and is never duplicated. Effective mode and Memory/HotSet changes keep the cache coherent.
  • Preserve ordinary media-shaped tool JSON in history retrieval; omit only validated media in the corresponding semantic positions. Exhausted missing-item reads fail explicitly, while intermediate scan pages remain successful and resumable.
  • Add bounded list_windows, literal search, and paged read_item recovery with authenticated cursors, aggregate byte/row caps, oversized-row handling, and manual-reset privacy floors. Private cross-process append receipts preserve cursors through tracked appends and expire them after observable untracked changes; fixed stamps cannot detect same-size rewrites with unchanged metadata. Automatic rewrites preserve raw reset evidence. Manual reset floors remain unskippable when requesting older context windows.
  • Before rollover cleanup or append, prepare the complete pinned candidate with the existing builder. Reject oversized system/memory/advertised-tool payloads while preserving the old window; persist exact accepted rows and start the same prepared request without replaying assembly. Scope admission abort forwarding to preparation, retaining accepted-wake delivery on failed rollback and normal interruption/disposal behavior.
  • Conservatively account for omitted JSON structure and escape expansion in token guards. In-process emergency retries restore only the original accepted baseline of copied file snapshots after a current-owner commit, so later updates continue without rereading snapshots or retaining unrelated old-window files.
  • Preflight fully assembled requests, including fallback models, against the hard context ceiling. Count ordinary strings/JSON as text and reserve media allowances for genuine media boundaries. Size only advertised Tool Search schemas without pruning executable tools; recheck transformed messages and activated schemas before each provider step. Late overflow blocks without an emergency rollover, preserving completed results. Restore validated persisted usage after restart. Rejected inputs and owned snapshots are empty assistant capsules excluded even by preceding provider assembly, with originals retained solely for display/edit/export. Keep manual/idle compaction available; continuous compaction and effective RLM take precedence.
  • Keep session_history under ordinary inherited tool policy: built-in agents retain access through wildcard grants, while narrow custom agents opt in. Missing access blocks rollover before sealing existing context.
  • Add experiment settings, rollover dividers, warnings/countdowns, mobile stories, ADR-0005, and user/tool documentation.

Validation and dogfooding

  • Earlier full regression pass: 1,294 tests, including lifecycle, stream settlement, request assembly, real-disk history recovery, policy, and memory regressions.
  • Latest reset-floor/provenance revision: 3,737 passing tests across 106 suites, both static gates, and independent safety/merge review. This includes the latest reconnect/replay ownership merge, preparation/retry/cancellation integration, active-schema checks, semantic media counting, and reset-window kind/privacy regressions. Real-Node/real-tokenizer checks verify off/on/off selection of 8/9/8 entries, unchanged normal selections and their full byte budget, and a bounded ninth excerpt. Earlier tool-policy tests and smoke cover inheritance, explicit grants, omissions, and on-send/emergency rollover gates.
  • 10 passing Storybook cases and make static-check-full; repeated make static-check immediately before push. Nix-only format checks were skipped because Nix is unavailable locally.
  • Isolated live backend with a controlled Anthropic-compatible provider: warning → notes write → two automatic rollovers → prior-window list/search. This validates the real request/tool/lifecycle paths, not external model reasoning.
  • Live read_item calls used snake_case inputs and recovered seven character pages that exactly reconstruct an 872-character historical item. Default 8,000-character reads are separately covered by automated tests.
  • Desktop 1900×1080 and phone 375×667 checked through the Storybook manager; keyboard warning expansion and expanded tool details fit without horizontal overflow. Rejected inputs remain visible but are not offered for retry; editing and a smaller draft remain available even when the persisted row is an empty capsule.
  • Recorded real-disk smoke under Node with two backend processes: cooperative append preserves the cursor; same-length interior rewrite plus untracked append expires it; ordinary stream completion, edit/fork, and percentage truncation preserve malformed reset privacy floors; legacy filtering excludes rejected inputs and owned payloads without relying on the new rejection flag.
Live rollover recording
live-validated-rollovers.webm
Live history paging recording
read-item-paging.webm
375px phone verification

Rollover warning, history tool, and context countdown at 375px

  • Latest recorded proof uses real Node/tokenizer and disk-backed history: textual data URLs reach the hard ceiling while typed media retains its allowance, and post-reset windows report reset kind without exposing old content. Six real StreamManager/history cases with a mocked provider verify inactive catalogs, fitting/oversized activation, search-off, thinking rebuilds, and fallback limits. This is integration evidence, not live-provider reasoning.

  • 26 full-builder admission cases cover complete pinned system/schema admission before reset, deferred catalogs, both rollover paths, cancellation/revocation/disposal, failed append, actual prepared runtime use after sandbox discard, lazy fallback limits, cache/goal/sequence semantics, and both rollback outcomes. Recorded on the final production revision with controlled provider/model discovery.

  • Latest recorded proof compares punctuation-only JSON against real Node tokenization, reads/searches media-shaped ordinary JSON through disk-backed history, checks missing-item failure status, and exercises five real-file emergency-tracking scenarios. Structural accounting is a conservative allowance, not exact serialized-token measurement.

  • Real-Node checks and chat/archive/mixed-boundary regressions verify that skips cannot cross a manual reset. The provenance fixture now guarantees an observable timestamp change while retaining its real same-size rewrite and epoch-invalidation assertion; 2,000 repeats passed across normal storage and tmpfs. Separate Node/Bun probes demonstrated unchanged-metadata collisions, documented as a bounded-receipt limitation—not a production detection fix.

Risks and implementation notes

  • Rollover deliberately discards earlier messages from the active provider request, not from stored history. Recovery depends on bounded history access; an explicitly disabled session_history tool blocks threshold rollover instead of silently losing access.
  • Hard text guards use resolved real encodings; provider-family fallbacks and media/framing allowances remain estimates. Unknown model limits cannot receive the same preflight guarantee.
  • The implementation follows existing provenance/admission rules: branch-summary registrations are cleared only after publication, and the initiating send reconciles its own context-mutation epoch. Warnings use a durable prefix row plus a correlated queued continuation.
  • The append receipt assumes cooperative writers honor the history lock and that the receipt is private. It detects untracked changes between transactions/pages, not hostile filesystem writes racing inside a certified append syscall/stat interval (documented in ADR-0005).
  • Older builds intentionally hide rejected capsule contents while retaining originals for upgrade. Truncation markers keep legacy decoded hashes alongside versioned byte hashes for crash recovery across versions.
  • This is opt-in; manual reset and compaction compatibility paths have dedicated regressions.

📋 Implementation Plan

Token-budget context windows (Codex-style) for xum — synthesized plan

Goal

Opt-in token-budget context strategy replacing lossy LLM summarization for automatic context management with:

  1. Hard window rollover — near the limit, start a fresh provider context (reset boundary). Prior transcript stays on disk / UI / export.
  2. session_history tool — list prior windows, search them, page items back in, all bounded.
  3. Cross-window notes — conventional /memories/workspace/context-notes.md, optionally appended as a ninth <hot_memories> entry while token-budget mode is active.
  4. Proactive budget warning — one durable, in-band message per window before rollover, telling the agent to flush state into notes.

Non-goals (v1): changing /compact, idle compaction, continuous/RLM compaction (they take precedence; rollover disabled when on); per-turn "N tokens left" injection; post-compaction diff/skill carryover across rollovers (D8); a new durable-event kind (the warning is a chat row, replayable by construction); a transcript migration or DB.

Verified seams (explorer-confirmed, file:line)

Seam Where Fact that shapes the design
Boundaries docs/adr/0003…, src/common/constants/contextBoundary.ts, compactionBoundary.ts:151-170 Reset boundary (contextBoundaryKind:"reset") → exclusive provider slice; compaction → inclusive. Both found by byte needles (HistoryService.BOUNDARY_NEEDLES L794) and rotate sealed epochs to chat-archive.jsonl (rotateSealedHistoryUnlocked L1760).
Reset writer workspaceService.resetContext L12413-12620 createMuxMessage(createContextResetBoundaryMessageId(),"assistant","",{contextBoundaryKind:RESET}), advanceContextMutationEpoch, clearUsageState, clearPostCompactionState, sandbox scope discard; rejects while a turn is active.
On-send trigger agentSession.sendMessage L3786 checkBeforeSend → L3813 shouldCompactBeforeSend → L3844-3921 builds compaction request; user message is not persisted on that branch (L3896); otherwise persisted at L4051. Provider history loaded later in streamWithHistory L5504 (getHistoryFromLatestBoundary). A pre-send rollover can append rows and then let the same sendMessage continue.
Mid-stream trigger forward("usage-delta") L6369-6461 → checkMidStreaminterruptForCompaction L5201 (stopStream({abortReason:"system"}), waitForIdle, sendMessage("Continue",…)). Listener timing based; we do not reuse it for the decision.
Step-end stop streamManager.createStopWhenCondition L2214-2264; SDK evaluates after all sibling tool results settle; StopCondition may be async and sees steps[i].usage / toolResults. Existing predicate request.hasQueuedMessages?.("tool-end"). Authoritative budget decision lives here.
Partial flush completeToolCallflushPartialWrite L2728 → writePartial L1244. Stream end → commitPartial (write-locked). Tool results are durable before stop condition runs.
context_exceeded streamManager.categorizeError L4920-5008 string/code match; agentSession.handleStreamError L6181-6228: normal turns fail terminally (non-retryable). Emergency rollover hook (Phase 3b).
Queue messageQueue.addOnce(message, options, dedupeKey, internal) L482; dispatchMode default "tool-end"; agentSession.hasQueuedMessages(mode) L7200; sendQueuedMessages L7508 → sendMessage. Heartbeat enqueue precedent workspaceService.ts:15250-15272 (muxMetadata, queueDispatchMode, internal:{synthetic, queueDedupeKey, skipAutoResumeReset, yieldToQueuedMessages}). Reused verbatim for warning / continue dispatch.
Thresholds autoCompactionCheck.ts:37-44,119,124: {shouldShowWarning, shouldForceCompact, usagePercentage, thresholdPercentage}; force = threshold+5%, warn = threshold−10%; getContextTokens = input+cached+cacheCreate. compactionMonitor.getThreshold() < 1 gates auto. Reuse; add contextTokens/maxTokens to result.
Experiments agentSession.ts:4944-4947 pattern options?.experiments?.x ?? aiService.isExperimentEnabled(EXPERIMENT_IDS.X); ExperimentsSection.tsx. Same pattern for TOKEN_BUDGET.
Message schema message.ts:935-978: synthetic, uiVisible, compacted strict union, contextBoundaryKind, muxMetadata; orpc muxMetadata: z.any() (L174). New discriminators go in muxMetadata only.
History reads iterateFullHistory(ws, dir, visitor) L1278: 256 KiB chunks, early exit, no giant-line cap (carryover grows unbounded); in-process mutex only for reads; historySequence monotonic across chat+archive (L2165), legacy rows may lack it. getHistoryBoundaryWindow fallback reads both files fully. Needs a bounded scanner variant.
Hot set src/common/constants/memory.ts: 8 items / 48 KiB / 12k tokens / 16 KiB per item; rankHotSetCandidates L57 (pinned first; unpinned 0-access filtered L62); selectHotMemories L105; appended in turnContextAssembler.ts:819-821; gated by memory + memory-hot-set. Ordinary selection stays unchanged; active token-budget notes are additive.
Tools toolDefinitions.ts:2301-2309 (ptcExcluded?: string), getAvailableTools options L3577; ToolConfiguration has workspaceId but no historyService; built in turnRequestBuilder.buildToolsForModel L1967-2005; TOOL_REGISTRY getToolComponent.ts:75; TOOL_NAME_TO_ICON ToolPrimitives.tsx:243.
Carryover modelMessageTransform.injectPostCompactionAttachments anchors on compaction boundaries only. D8.
ADRs docs/adr/0003…, 0004… exist → new one is 0005.

Decisions

D1 — Rollover = reset boundary + separate synthetic user lead-in. Boundary row: role:"assistant", contextBoundaryKind:"reset", muxMetadata:{type:"context-window-rollover", rolloverId, reason:"on-send"|"mid-stream"|"context-exceeded", previousWindowId, flushOpportunity:boolean, contextTokens, maxTokens}. Immediately after: role:"user", synthetic:true, uiVisible:false, muxMetadata:{type:"context-window-lead-in", rolloverId} with deterministic guidance (notes file if present is preloaded; session_history if available; prior window id; for mid-stream: "your previous turn was interrupted by a context rollover; continue the task"). Slicing stays ADR-0003 (exclusive at reset). Boundary, lead-in and the continuation (user/Continue) row are written in one appendManyToHistory call (D3). Not a compaction-shaped row (compacted union is strict → downgrade risk; ADR-0003 forbids fake summaries).

D2 — Gate: EXPERIMENT_IDS.TOKEN_BUDGET = "tokenBudget" (global toggle in ExperimentsSection). Read via the L4944 pattern. Precedence: continuousCompaction or RLM on → rollover disabled (log.debug), summarization as today. getThreshold() >= 1 (auto disabled) → no proactive warning or threshold rollover, but the D4.4 hard-ceiling preflight/block still applies while the experiment is on. session_history availability (author-approved revision): with the experiment on, session_history is registered as a read-only, workspace-scoped tool and follows ordinary inherited agent/caller tool policy. Access requires an explicit tool name or matching wildcard grant; enabling the experiment never widens a narrow allowlist. Built-in Exec and Plan grant it through .*, and Explore inherits that grant. Omission or a later matching deny blocks rollover before existing context is sealed, returning "context_budget_blocked" with guidance (enable session_history, /compact, /clear --soft). A fitting request in an empty/internal-only window remains allowed. Rejected: a special baseline grant, silently summarizing, or rolling over without retrieval.

D2.1 — Request-middleware admission. Before rollover cleanup or publication, reconcile lazy workspace plugin hooks without constructing models/tools and capture an immutable ordered snapshot of applicable request-assembly registrations. Generic tool-mutating middleware is uncertified and blocks rollover before existing context is sealed; it is never overridden by restoring a denied tool. Explicit workspace scopes are enforced in dispatch. Context-only adapters receive no tool references and write back system text alone, so sandboxed context hooks remain supported. Run the admitted snapshot through primary/fallback requests, thinking context, emergency and in-process automatic retries; registration changes affect subsequent admissions. The snapshot is never serialized. Plugin revocation/epoch checks remain live and dropped mounts are reacquired. Ordinary non-rollover requests retain live middleware behavior. Validate blocked/benign uncertified chains, workspace isolation, lazy first-rollover context, registration races, fallback/retry continuity, revocation, and context-only projection.

D3 — One rollover path; the settled-step decision stops the stream directly and only requests the rollover.

  • Authoritative decision in stopWhen. StreamRequestConfig gets onStepSettled?: (step: {usage, outputTokens, toolResultChars, imageParts}) => Promise<"continue"|"warn"|"rollover">; createStopWhenCondition awaits it and returns true itself for "warn"/"rollover" — independent of hasQueuedMessages("tool-end"), so a queue holding only a turn-end entry can never let the stream run past the ceiling. AgentSession implements it using the model actually streaming (from the stream context, not the primary model): normalize usage exactly as updateUsageStateFromModelUsage, compute projected = contextTokens + outputTokens + ceil(toolResultChars / 4) + IMAGE_TOKEN_ESTIMATE × imageParts, evaluate evaluateStepBudget (Phase 2) against checkAutoCompaction thresholds and the hard ceiling (modelContextLimit − OUTPUT_RESERVE_TOKENS):
    • "block" when the measured hard projection reaches the ceiling with automatic handling disabled: stop at the settled boundary without warning, rollover, Continue, retry metadata, or quarantine of already-executed input; preserve all sibling tool results.
    • "rollover" (automatic handling enabled and projected ≥ forceThreshold || projected ≥ hardCeiling) → latch this.pendingRollover = {rolloverId, reason:"mid-stream", flushOpportunity: projected < hardCeiling}; if messageQueue.isEmpty(), addOnce("Continue", {...internal-resume options as built at L5231-5249, queueDispatchMode:"tool-end"}, CONTEXT_CONTINUE_DEDUPE_KEY, {synthetic:true, skipAutoResumeReset:true, …}); otherwise the already-queued real input is dispatched first and receives the rollover (it would have needed it anyway). Stream ends at the step boundary; commitPartial commits the assistant row with all tool results paired; sendQueuedMessages dispatches.
    • "warn" only when shouldShowWarning && !warningEmittedInWindow && projected + WARNING_RESERVE_TOKENS < hardCeiling (the warning turn must itself fit; otherwise the evaluator returns "rollover" with flushOpportunity:false). If messageQueue.isEmpty(), addOnce(warningText, …, CONTEXT_WARNING_DEDUPE_KEY, …) with muxMetadata:{type:"context-budget-warning", contextTokens, maxTokens}; if not empty, the warning is emitted on-send as a prefix row (D6) ahead of the queued input. Latch the in-memory claim for this window.
    • usage-delta keeps updating usage state but no longer makes decisions. interruptForCompaction is untouched (still used when the experiment is off).
  • Rollover executes only in sendMessage (on-send), as prefix rows of the same append. Branch before L3844: if (tokenBudgetActive && rolloverEligible (D4.1) && (this.pendingRollover || evaluateStepBudget(projectedOnSend (D4.3)) === "rollover"))preflight (D4.2) → build prefixRows = [boundary, leadIn] (plus the on-send warning row when applicable) → continue the same sendMessage; at the existing user-message persist point (L4051) first run applyContextResetSideEffects() (below; idempotent, benign if the append then fails), then call appendManyToHistory([...prefixRows, userMessage]) so boundary, lead-in and the user's (or Continue) message land in one write — no lost user input. If either step throws, the send fails visibly before any provider build. After the append: clear the latch, emit chat-events; history is loaded from the new boundary at L5504. Skip turn snapshots for the boundary rows as the compaction branch already does.
  • applyContextResetSideEffects(reason): extracted from resetContext L12535-12614 and shared with it: advanceContextMutationEpoch, clearUsageState(), clearPostCompactionState(), clearPendingBranchSummary, discard context-scoped PTC/sandbox scope and stale refinement/retry state. Preserved: session history, costs/lifetime usage, MemoryService data, task handles and intentional background jobs, queued real inputs, goal acknowledgment state (rollover does not call requireUserAcknowledgment; that is user-clear semantics). Asserts: no active stream, turnPhase admits, boundary is a reset marker, three historySequences strictly increasing.
Why not stopStream + interruptForCompaction (Fable) or a separate journal file (Astra)?
  • Graceful stop via stopWhen finishes the step: tool calls and results are committed together by the normal stream-end → commitPartial path. An abort mid-step can leave the pairing to recovery. Dispatch reuses the heartbeat mechanism (queue), so ordering with real user input is already solved.
  • With prefix rows, every transition is one atomic history append with no external side effect: (a) nothing on disk, or (b) boundary + lead-in + continuation together. There is no intermediate persisted state to journal. The only in-memory state (pendingRollover, queued Continue) is derivable: after a restart the completed old turn is on disk and the next sendMessage re-evaluates usage seeded from history (seedUsageStateFromHistory) and rolls over then. A journal would add a second source of truth to reconcile without adding a state it could protect. rolloverId is stamped on all three rows for auditing/tests.

D4 — Loop guard + fresh-request preflight (no chain of empty windows).

  1. Already-fresh guard. A rollover is eligible only if the active window contains ≥1 provider-eligible row that is not token-budget internal (muxMetadata.type ∉ {context-window-lead-in, context-budget-warning}, not compaction-request, not rlmPreservedTailCopy). An internal-only window is treated as already fresh: no second boundary, the message is sent normally if it fits (D4.2), log.warn once. This is also the recovery rule for an incomplete rollover batch (D5).
    Emergency rollover eligibility excludes all continuation-owned preludes, including synthetic assistant/family rows, so a crash-resumed already-fresh batch cannot create another rollover. Real older context still permits one recovery transition.

  2. Fresh-request preflight (cheap, pre-history). Against the resolved model for this send (modelForStream, after fallback-route resolution at L3786): await estimateFreshRequestTokensForModel(...) ≥ hardCeilingno rollover, no provider call; sendMessage returns a visible error Result "context_budget_blocked" ("This message plus the system context does not fit in a fresh context window for ; shorten it, remove attachments, or use a larger model") — same surface as existing pre-send validation errors. Without measured system/schema overhead, use the existing model-scaled SYSTEM_FLOOR_TOKENS_ESTIMATE fallback. Historical request input includes old user/history content and must not be reused as fixed overhead. The final assembled preflight remains authoritative. Fresh and assembled hard text guards use real resolved encodings with a per-call approximation bypass. Oversized strings use codepoint-safe chunks and boundary slack; encoding failures fail closed. Provider-family encodings and media/framing allowances remain estimates, with provider overflow handling as a backstop.
    Recheck the complete already-materialized file/skill/MCP/family prelude batch before reset cleanup or publication. Expand dynamic inputs once per send, count their actual payload rather than invocation text, preserve snapshot/file-tracking eligibility on rejected retries, and revalidate cancellation/admission after asynchronous counting. Over-budget batches retain only safe rejection capsules and apply manual goal safety without sealing existing context. Emergency recovery applies the same admission to the complete copied/deduplicated retry prelude against the actual failing model and captured provider configuration before cleanup or reset publication.

  3. On-send projection includes the unsent tail. projectedOnSend = seededContextTokens + outputTokens(lastAssistant) + estimate(tool results of the last assistant row) + estimate(user message + attachments); the last step's provider usage never counts its own trailing tool results, so this is what makes the post-restart case (D5) and the mid-stream latch produce the same decision from history alone.

  4. Per-attempt hard preflight after final assembly (Phase 2/3). In turnRequestBuilder.build, after system prompt, tools, memory and messages are assembled for the attempt's resolved model, run checkAssembledRequestBudgetForModel using the attempt’s resolved model/capability encoding, sanitized wire text/tool schemas, and media/framing allowances and compare with that model's hardCeiling. Over → typed build outcome {kind:"context_budget_exceeded", model, estimate, hardCeiling} returned before any network call, handled in agentSession ahead of generic failure/retry: if tokenBudgetActive and the window is rollover-eligible (D4.1) → emergency rollover (flushOpportunity:false, continuation = same user message) and rebuild once; otherwise → "context_budget_blocked" visible result. Runs for the initial attempt and every fallback attempt. Phase 3b (provider context_exceeded) remains the backstop for estimator misses, not the primary mechanism. Omitted JSON punctuation and escape expansion receive a conservative one-token-per-byte allowance; real leaf encoding and media exclusions remain intact. Ordinary strings and JSON count as text even when they resemble data URLs or media objects; only genuine SDK/model media at explicit part boundaries and supported sanitized tool wrappers receive media allowances. With Tool Search, count only the actual advertised schema subset while retaining all tools for execution. Recheck each provider step after thinking/media transforms using the pinned attempt limit and actual model, including newly activated schemas. Per-step violations use terminal ContextBudgetBlockedError (including fallback step zero), preserving completed tool results without an emergency-rollover/catalog loop; builder preflight remains the recoverable seam. Before any on-send or emergency rollover clears context state or appends a boundary, use that same builder seam to prepare the complete pinned future request (system/middleware, fresh memory context, advertised schemas, exact candidate rows). Rejecting admission disposes preparation resources while preserving the old window and its context-scoped state. Persist the exact accepted rows and start the same one-shot prepared request; do not repeat tool/system/hook or prelude assembly, register an assistant placeholder/stream before acceptance, or preconstruct fallbacks. Promote the candidate memory cache only after successful append; late-bind durable sequence and operation/thinking callbacks at start. Preview only prospective goal-tool availability without goal writes, preserving queued-user consent. Limit the admission abort link to candidate preparation and discard a candidate already aborted before detachment. After preparation, explicit admission/rollback guards decide cancellation versus retained delivery, including failed-rollback acceptance; accepted-turn interruption/disposal remains live.

  5. If a window rolls over after a single assistant turn, log.warn (limit too small for system prompt + hot set).

  6. Auto disabled (getThreshold() >= 1) under tokenBudget: no proactive warning/rollover, but D4.4 still applies — an over-ceiling request is blocked visibly rather than knowingly sent. With the experiment off, behavior is unchanged.

  7. Rejected manual intervention still applies goal safety. When rejection retention reports an actionable manual message, apply the same goal pause/acknowledgment and continuation cleanup as pricing rejection. Synthetic/internal and blank input are not manual interventions. Validate with real goal/history services.

D5 — Crash-safe recovery contract (derived from history, no replayed side effects).
Intended persisted states: A = old window complete, no rollover rows; B = [boundary, lead-in, continuation] appended in one call (same rolloverId). In-memory only: pendingRollover latch, queued Continue/warning. Ordering inside the rollover send: (1) applyContextResetSideEffects() before the append — every step is idempotent and benign if the append then fails (usage re-seeds from history, discarded PTC scope/post-compaction state is context-scoped and would be dropped by the boundary anyway); (2) appendManyToHistory(B); (3) clear latch, emit chat events; (4) streamWithHistory. If (1) or (2) throws, sendMessage fails visibly before any provider build — never stream with stale carryover/PTC state.

  • Crash in A (incl. after stopWhen returned true and commitPartial ran, before the queued Continue was dispatched): the old assistant turn is complete on disk with tool pairs intact (commitPartial is write-locked and atomic; an interrupted stream follows existing partial recovery). The queue is not resurrected; the turn is paused visibly (assistant row complete, no divider yet). The next real sendMessage recomputes D4.3 from history — including the trailing tool results that caused the stop — and rolls over then. No auto-resume of tasks after restart in v1 — a deliberate choice: nothing the user typed is lost and no side effect is replayed.

  • Crash/partial write during B (appendFile of several lines may persist a complete prefix, and tolerant parsing drops a truncated trailing line): the window on disk is [boundary] or [boundary, lead-in] → D4.1 treats it as already fresh; the next user message is appended normally into that window, no second boundary. Recovery is the D4.1 rule itself; no marker needed because the only lost row is the continuation, whose send already failed visibly. Test this exact case (truncate chat.jsonl after row 1 and after row 2).

  • After B: normal stopped-turn semantics. A second boundary requires a new non-internal row in the window (D4.1) and the latch is cleared under the same sendMessage that appended B, so duplicates are impossible by construction.

  • Supersession: explicit /clear (either kind), /compact, edit, delete, interrupt, heartbeat compaction, or fork clears pendingRollover and the warning claim and drops the queued Continue via its dedupe key (hook where clearUsageState()/advanceContextMutationEpoch already run). resetContext rejects while a turn is active; rollover only runs from sendMessage under the existing admission gates, so the two never interleave.

  • Emergency (giant single tool result / model switch / provider context_exceeded): same path with flushOpportunity:false; the giant result is committed to the old window before the boundary (never deleted, never re-executed); the lead-in names it as retrievable via session_history.

  • Tests fault-inject spyOn(historyService,"appendManyToHistory").mockRejectedValueOnce, truncate the last line of chat.jsonl after a rollover, and simulate restart (new AgentSession over the same createTestHistoryService()), asserting ≤1 boundary per rolloverId, no orphan tool call, no duplicate Continue, no lost user text in the success path.

  • In-process emergency retries retain only the original canonical tracking baseline for the accepted file snapshot actually copied into the retry. Restore synchronously after a successful, still-current rollover commit; never reread the snapshot, use a newer tracked hash, or restore unrelated old-window files. Rejected/deferred snapshots do not establish a baseline; compaction clears it with normal tracking state.

  • Preserve trunk’s centralized PreparationAttempt, scoped/generation-guarded retry, and awaitable cancellation/disposal contracts. Budget-failure callbacks settle through the preparation owner before terminal policy; asynchronous emergency handoffs recheck current-turn admission, and direct/resumed requests retain the admitted snapshot and rollover metadata.

  • Append provenance is bounded fixed-stamp evidence, not content-identity proof. Same-size rewrites with unchanged observed identity/size/timestamps, including benign same-tick filesystem collisions, cannot be detected without stronger filesystem/write isolation or whole-prefix verification. The unknown-write epoch-invalidation fixture must make the stamp change observable while retaining real-write, same-size, changed-content, and epoch-invalidation assertions; do not claim a production fix for the unchanged-stamp limitation.

D6 — Warning: once per window; rollover wins over warning. Text: "Context window ~N% used (X of Y tokens). If you have state worth keeping, write/update /memories/workspace/context-notes.md now (essential state first, ≤ 8 KiB), then continue the current task without commentary." Emitted either mid-stream (D3 queue) or on-send as a pre-turn role:"user", synthetic:true, uiVisible:true row before the user's message. Latch = history-derived (a context-budget-warning row exists in the active window) plus in-memory claim. Omit the notes sentence when memory is off or read-only; say writes are unavailable and name session_history.

D7 — Additive context notes (author-approved revision). Preserve ordinary hot-memory ranking, eligibility, eight-item selection, and byte/token budgets unchanged. When effective token-budget mode is active, append an existing /memories/workspace/context-notes.md after that normal selection as one optional extra entry, allowing up to nine total. Do not duplicate notes already selected normally. Bound only the additional excerpt by the existing CONTEXT_NOTES_RESERVED_BYTES = 8 * 1024 and CONTEXT_NOTES_RESERVED_TOKENS = 2_000 allowance, including its formatting; do not shrink or evict ordinary entries to fit it. Missing, unreadable, binary, or unfittable notes must not discard the normal selection. No file creation or pin/stat mutation. Memory, Memory Hot Set, and the normal effective memory policy remain required. Inactive token-budget mode gives the path no special treatment. Pass the effective per-turn mode through memory construction and keep cached contexts mode-correct across toggles and explicit overrides; preserve existing memory-operation invalidation.

D8 — Carryover: rollover behaves like /clear --soft (clearPostCompactionState()); edited-file diffs/skills are not re-injected. Follow-up (not v1): anchor injectPostCompactionAttachments on rollover boundaries too.

D9 — session_history tool (ptcExcluded: "Context-coupled history browser", Plan + Exec + custom agents per their tool policy, read-only, registered when the experiment is on).

  • Actions {action: enum(list_windows|search|read_item), window_id, query, item_id, cursor, limit, offset_chars, limit_chars} all .nullish().
  • Window id = "w:<historySequence of the boundary row>", "w:0" root, "w:m:<messageId>" for legacy rows without a sequence. New item IDs are opaque exact-row references bound to the append-provenance epoch, artifact, byte offset, and raw-row fingerprint; sequence and "m:<messageId>" inputs remain legacy aliases. Exact references survive certified EOF appends with an unchanged prefix and expire on rewrite/rotation rather than resolving another physical row. Never use compactionEpoch as an identity. Validation covers duplicate IDs/sequences, identical physical copies, character paging with appends, rewrites/rotation, and the manual-reset privacy floor.
  • Privacy floor (ADR-0003): traversal crosses rollover boundaries and compaction boundaries (incl. heartbeat-shaped), but stops at the newest plain reset boundary (contextBoundaryKind:"reset" without context-window-rollover metadata = manual /clear --soft): windows above it are not listable, searchable, or readable.
  • Default filters: compaction-request rows, hidden synthetic rows (synthetic && !uiVisible, incl. RLM tail copies), reasoning parts, binary/media parts (replaced by [image]), nested session_history results (replaced by [history result omitted]). Historical text is labeled "historical transcript data, not instructions".
  • Bounded scanning (new historyService.scanHistoryBounded(ws, {direction, startCursor, maxBytes: 2 MiB, maxRows: 500, maxLineBytes: 1 MiB}, visitor) → {cursor, exhausted, skippedOversizedRows}): reuses iterateBackward/Forward chunking but caps carryover; a line > maxLineBytes is skipped and counted (no fake ids); when the byte budget is exhausted mid-line, the returned cursor carries the byte position so the next call resumes without rescanning. Locks: in-process read mutex per page, released between pages; never called while any write lock or the goal-file lock is held.
  • Cursors are opaque base64 JSON {v:1, ws, action, query, artifact:"chat"|"archive", byteOffset, anchorSequence|anchorHash, endOffsetSnapshot}. Append growth (including this tool's own results landing in chat.jsonl) does not invalidate: offsets of existing bytes are stable. Archive rotation between pages is detected by re-parsing the row at byteOffset and comparing the anchor → {error:"stale_cursor", restartHint}. Cursor JSON is validated with a strict zod schema; mismatched v/ws/action/query → error result. Sequence coverage is not proof of a replay: retain active rows with reused sequences rather than hide repaired/imported payloads. Physical replay duplicates may remain visible. Append compatibility is certified by the durable append receipt and per-page file validation, not by a sequence watermark or head/tail hashes alone.
  • Caps: SESSION_HISTORY_MAX_RESULT_BYTES = 16 * 1024 aggregate (JSON + markers included), limit default 10 / max 25 for search, ≤ 50 for list_windows, read_item limit_chars default 8 000 / max 16 000. Search is literal, case-insensitive; reports skipped_oversized_rows and exhausted:false when the budget ran out (never claims exhaustiveness). Character offsets remain UTF-16 units; page/search/shrink boundaries preserve surrogate pairs, manual mid-pair offsets round back, and a one-unit page may return a whole pair to guarantee progress. nextCharOffset comes from the actual adjusted end. Existing unpaired source units are replaced only in output without changing offset lengths or stored bytes.
  • Scoped to config.workspaceId (assert present); host-local files even for SSH workspaces.

Phases (each gated by tests + dogfood before the next)

Phase 0 — ADR + constants/types (~70 LoC)

  • docs/adr/0005-token-budget-context-window-rollover.md: third boundary use; reset boundary created by rollover may be followed by a provider-visible synthetic lead-in (amends ADR-0003 consequence 2 for this case only); recovery contract (D5); privacy floor (D9).
  • src/common/constants/experiments.ts TOKEN_BUDGET; src/common/constants/contextBudget.ts (notes path, reserved bytes/tokens, dedupe keys, OUTPUT_RESERVE_TOKENS, IMAGE_TOKEN_ESTIMATE, SYSTEM_FLOOR_TOKENS_ESTIMATE, tool caps, scan caps).
  • src/common/types/message.ts: muxMetadata variants context-window-rollover, context-window-lead-in, context-budget-warning (+ type guards isTokenBudgetInternalMessage, isRolloverBoundary).

Phase 1 — Bounded session_history tool (~380 LoC)

Ship the recovery path before any automatic reset (Astra ordering).

  • historyService.scanHistoryBounded + cursor codec (src/node/services/historyCursor.ts).
  • src/common/utils/messages/contextWindows.ts: pure bucketing/rendering (bucketWindows(rows), renderItemPreview, privacy-floor predicate, filters; reuse extractMessageText).
  • src/node/services/tools/session_history.ts; toolDefinitions.ts schema + getAvailableTools({enableSessionHistory}); tools.ts historyService?: HistoryService on ToolConfiguration; wire in turnRequestBuilder.buildToolsForModel; TOOL_NAME_TO_ICON.session_history = History; GenericToolCall fallback.
  • Tests (session_history.test.ts, historyService.scanBounded.test.ts, real history on disk): windows across mixed legacy/reset/compaction/rollover boundaries; privacy floor; legacy rows without sequence addressable; search/limit/window filter; read_item paging; 1 MiB+ row skipped with count; 2 MiB budget exhaustion → resumable cursor without rescanning (assert bytes read); cursor survives appends made by the tool's own result; archive rotation between pages → stale_cursor; aggregate ≤ 16 KiB (assert); hidden/media/nested-result omission; wrong-workspace cursor rejected.
  • Dogfood gate: seed a long history, ask "what did the first test say about X?" → list_windows → search → read_item; page a long item; screenshot tool card at 375 px and desktop.

Phase 2 — Additive notes + budget evaluator (~180 LoC)

  • memoryHotSet.selectHotMemories: unchanged ordinary selection followed by the optional bounded notes append (D7). Tests: eight competing pins and normal byte/token budgets stay intact; notes become a ninth entry only while effective token-budget mode is active; inactive mode matches ordinary behavior; zero-access notes, deduplication, malformed/unreadable files, independent excerpt limits, and mode-correct caches/overrides. Memory-off and policy-disabled preloading remain disabled; readonly memory does not authorize writes.
  • src/common/utils/compaction/contextBudget.ts: pure evaluateStepBudget({contextTokens, outputTokens, toolResultChars, imageParts, modelContextLimit, threshold, warningEmitted}) → {decision:"continue"|"warn"|"rollover"|"block", flushOpportunity, projected, hardCeiling}, estimateFreshRequestTokens, estimateAssembledRequestTokens(payload) (D4.4), estimateToolResultChars(step). Extend AutoCompactionCheckResult with contextTokens/maxTokens.
  • turnRequestBuilder.build: after assembly, run estimateAssembledRequestTokens for the attempt's model and return the typed context_budget_exceeded outcome (no network) when over the hard ceiling (~40 LoC; wiring of the outcome into agentSession lands in Phase 3).
  • turnContextAssembler <memory-tool-guidance>: one sentence about the notes file when the experiment is on (gate is tested, wording is not).
  • Tests: evaluator boundaries (warn band, force band, hard ceiling wins, warning that would not fit → rollover with flushOpportunity:false, unknown limit → "continue" + log.warn, never zero-as-unlimited); turnRequestBuilder returns the typed outcome for an over-ceiling assembled payload and makes no provider call.

Phase 3 — Rollover + warning + recovery (~430 LoC) — the switch-over gate

  • streamManager.ts: onStepSettled request field; createStopWhenCondition awaits it and returns true on "warn"/"rollover" before the existing queue check (~20 LoC).
  • src/node/services/contextWindowRollover.ts: buildLeadInText, buildBudgetWarningText, hasRolloverEligibleMessages, estimateToolResultChars(step).
  • agentSession.ts: onStepSettled implementation (D3), pendingRollover latch + supersession clears, sendMessage branch (D3/D4.1–4.3, prefix rows in the single append), handling of the context_budget_exceeded build outcome (D4.4: emergency rollover once or blocked result), "context_budget_blocked" Result, on-send warning row (D6). Extract applyContextResetSideEffects from workspaceService.resetContext and call it from both.
  • agentTools.ts/toolAssembly.ts: register session_history under the experiment and filter it through ordinary inherited tool policy (D2); blocked result when history access is omitted or disabled and existing context would be sealed.
  • Phase 3b (required — it is the fallback-model backstop, D4.3): in handleStreamError for context_exceeded on a normal turn with the experiment on and no deltas streamed → perform rollover (reason:"context-exceeded", flushOpportunity:false, continuation = the same user message re-appended in the fresh window, original row left in place) and retry streamWithHistory once; D4.1 prevents loops. Must run before the string-matched legacy compaction retry paths (maybeRetryCompactionOnContextExceeded only applies to compaction turns).
  • UI: CompactionBoundaryMessage.tsx label "Context window rollover"; context-budget-warning rows via CollapsibleMachineMessage (branch in MessageRenderer.tsx/displayedMessageBuilder.ts); verify at 375 px.
  • Tests (contextWindowRollover.test.ts, agentSession.tokenBudget.test.ts with createTestHistoryService() + mock AI router, streamManager.test.ts):
    • boundary+lead-in ordering/metadata/sequences; provider slice excludes boundary, includes lead-in; on-send persists user message after lead-in; payload after rollover contains no pre-boundary rows (via sliceMessagesForProviderFromLatestContextBoundary).
    • settled-step: a step whose tool results push projection past force threshold ends the stream at the step boundary with tool pairs intact, enqueues exactly one Continue, next sendMessage rolls over; queue already holding a tool-end message → no Continue enqueued, rollover still happens on dispatch; hard-ceiling jump from a single giant tool result → flushOpportunity:false, no warning.
    • warning once per window, not after rollover, suppressed when rollover wins; on-send and mid-stream variants; mid-stream skipped when a tool-end message is already queued.
    • D4: internal-only window → treated as fresh (no second boundary, message sent normally); oversized fresh request → blocked result, no provider call; over-ceiling assembled payload (D4.4) → emergency rollover once, then blocked on a second over-ceiling build; fallback attempt to a smaller model gets its own D4.4 evaluation; experiment off → unchanged path; continuous/RLM precedence; auto disabled → no warning/rollover but D4.4 still blocks; session_history omitted or disabled by effective policy → blocked before sealing old context; explicit/wildcard grants → tool included; built-in Exec/Plan/Explore retain access through inherited policy.
    • D4.3: after simulated restart, on-send projection counts the trailing tool results of the last assistant row and rolls over.
    • Phase 3b: provider context_exceeded on a normal turn → one rollover + retry; second context_exceeded in the fresh window → terminal failure (no loop).
    • stopWhen returns true on "rollover" even when the queue holds only a turn-end entry; that entry is dispatched and receives the rollover.
    • D5 fault injection: appendManyToHistory rejects → no rows, latch preserved, next send retries; restart between boundary and continuation → paused, no second boundary, next user message sends in fresh window; manual /clear --soft clears latch.
    • replay: replayRequestBuilder reconstructs the post-rollover request identically (lead-in/warning are ordinary rows).
  • Dogfood gate: two real rollovers (one during a multi-tool batch, one from an oversized tool output), warning → agent writes notes → after rollover <hot_memories> contains them (check devtools.jsonl), no compaction request written, older messages still paged in UI; restart the sandbox between boundary and continuation and show paused-not-corrupt.

Phase 4 — Settings + docs (~30 LoC + docs)

  • ExperimentsSection.tsx toggle (description names the precedence and session_history requirement).
  • ContextUsageBar/Section: label the window as "rolls over at N%" instead of "compacts" when the experiment is on (text only, no new controls).
  • User docs page (register in docs.json); ADR index if present.

Invariants & defensive checks

  • Exactly two rows per rolloverId (reset boundary then lead-in), strictly increasing historySequence; assert after write; at most one boundary per rolloverId on disk (test).
  • Rollover only from sendMessage (or the Phase 3b error handler, after the stream has terminated) with no active stream; never from a stream callback. stopWhen only stops and enqueues.
  • Boundary, lead-in and continuation are one append; a boundary is never persisted without its continuation in the success path.
  • Never delete or re-execute a tool result; rollover never splits a tool call from its result (graceful step stop only).
  • Provider payload after rollover has no row with historySequence ≤ boundary.
  • ≤1 context-budget-warning per window; assert before append.
  • session_history: assert(config.workspaceId); never returns rows older than the newest manual reset boundary; aggregate ≤ 16 KiB; scan ≤ 2 MiB / 500 rows / 1 MiB per line per call; never under a write lock.
  • Rollover never fires when experiment off, continuous/RLM on, or auto disabled; no provider call is made when D4 blocks; a request estimated over the hard ceiling for the attempt's model is never sent while the experiment is on.
  • applyContextResetSideEffects runs before the append and is idempotent; a failed append fails the send visibly before any provider build.
  • Unknown model context limit ⇒ no rollover decision (log.warn), never treated as 0 or ∞.

Compatibility

  • Downgrade: rows are a plain reset boundary + synthetic user rows; unknown muxMetadata.type preserved and rendered as generic hidden/synthetic rows; no new top-level fields, compacted union untouched. Lead-in wording is conditional ("if a session_history tool is available…").
  • Upgrade: no migration; experiment defaults off; existing summary/reset histories unchanged.

Dogfooding (evidence: screenshots + short recordings via attach_file; artifacts outside the source tree)

  1. KEEP_SANDBOX=1 make dev-server-sandbox DEV_SERVER_SANDBOX_ARGS="--clean-projects" as a background task; scratch project; enable API Debug Logs and experiments memory, memory-hot-set, tokenBudget; per-model threshold ~10%; cheap model.
  2. Drive with agent-browser (snapshot -ifill/click → re-snapshot); record ≤5-minute clips per checkpoint.
  3. Per-phase checkpoints as listed in Phases 1–3; Phase 4: legacy /compact, /clear --soft privacy floor (windows above it invisible to the tool), experiment off, 375 px and desktop layouts, keyboard access.
  4. Gates: bun test <touched suites>, make typecheck, make lint, make static-check sequentially after the last edit (re-run typecheck after any lint fix).

Net LoC estimate (product code only)

Recommended: ≈ 1 090 LoC (range 950–1 300): P0 70 · P1 380 · P2 180 · P3 430 · P4 30.
Alternatives considered: compaction-shaped rollover row (−150 LoC, rejected: downgrade/ADR risk); separate journal file + generic context-budget durable event + per-attempt estimator seam in aiService (+300–500 LoC, rejected: the single-append transition leaves no intermediate state to journal, warnings/lead-ins are ordinary replayable rows, and Phase 3b covers fallback-model overflow; see D3/D4/D5).


Generated with mux • Model: coder:openai/gpt-6-astra • Thinking: high

Generated with xum • Model: coder:openai/gpt-6-astra • Thinking: high • Cost: $1488.17

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_
Integration checkpoint; full validation follows the parallel recovery and budget components.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_
…licy

Add browser experiment snapshots, rollover labels, collapsible warnings, session history icon, full-app desktop/phone stories, and ADR/user documentation.

Depends on shared DisplayedMessage rollover/warning metadata fields owned by the integration branch.
Add shared DisplayedMessage fields for rollover boundaries and machine warnings. Clarify append/cleanup ordering and caller epoch synchronization in ADR0005.
…sals

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_
Keep historical recovery experiment-gated but independent of implicit agent allowlists, with explicit tool disables honored. Bound disk scanning, authenticate append-stable cursors, and enforce manual-reset privacy floors.\n\n---\n_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

Signed-off-by: Thomas Kosiewski <tk@coder.com>
---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_
Document the existing atomic temp-and-rename batch writer. Keep legacy/external partial prefixes as a recovery-test requirement rather than a current writer crash outcome.

---

_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high -->
Add pure budget decisions and media-aware request estimates, reserve existing
workspace notes inside hot-memory caps, and gate every provider assembly with
a structured over-budget result. Keep memory guidance permission-aware.

Validation: 217 targeted tests, 27 memory-policy gate tests, changed-file ESLint
and formatting pass. Typecheck awaits the parent-owned ModelFallbackOptions
error union widening from string to string | ContextBudgetExceeded.
Expose the final node ContextBudgetExceededError.details contract and add a
visible context_budget_blocked send result. Prevent automatic retries of local
preflight refusals and terminal budget blocks.

Validation: 126 targeted tests and changed-file ESLint/format checks pass.
Typecheck still awaits the parent-owned ModelFallbackOptions error union.
Forward final post-policy memory write availability for each primary and
fallback attempt so budget warnings never ask read-only agents to write notes.

Validation: request-builder/system-assembler and existing memory/intuition gate
tests pass. Parent-owned stream request type additions are integrated separately.
---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_
Add durable-history regressions for rollover admission, recovery, queue dispatch, warning attribution, bounded overflow retries, and cache invalidation. Behavioral execution awaits the sibling budget-helper module; targeted lint and formatting pass.\n\n---\n_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

Signed-off-by: Thomas Kosiewski <tk@coder.com>
Exercise the shared force buffer without prematurely resetting the warning band, allocate real history sequences for stopped partials, and assert persisted continuation attribution at its actual schema fields.

Validation: 169 tests pass across all three touched files; make typecheck, targeted ESLint, and Prettier pass.
Handle the desktop Expand sidebar control before navigating to Settings. Clarify the five-percentage-point rollover force buffer and hard-ceiling precedence without changing production UI labels.

---

_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high -->
Exclude current compaction requests from assembled token-budget preflight using
the resolved compact agent, explicit send metadata, or final effective user row.
Older compact commands never disable preflight for an ordinary current request.

Validation: six red-first identity regressions, 136 request/AIService/assembler
tests, ESLint and formatting pass. Standalone typecheck reports only the known
parent-owned fallback error union and contextBudgetMemoryWritable additions.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$36.44`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=36.44 -->
Use snake-case history inputs, explicit scan completion and oversized-row
markers, and a read-specific envelope budget so fitting default pages
retain all 8000 characters. Keep existing output IDs and scan cursors.
Preserve pre-turn provenance under token budgets and avoid repeating a published
rollover after an append acknowledgment error. Retain durable reset failure
diagnostics and display rollover countdowns at desktop and phone widths.
Regenerate tool docs for the corrected bounded history API.

Validated with 1,120 regression tests, eight Storybook cases, and
make static-check-full. Live evidence covers notes, warnings, automatic rollovers,
and bounded prior-window recovery.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_
@mintlify

mintlify Bot commented Sep 5, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
Mux 🟢 Ready View Preview Sep 5, 2026, 2:03 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review


Generated with mux • Model: coder:openai/gpt-6-astra • Thinking: high

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Security Review

Here are some automated security review suggestions for this pull request.

Reviewed commit: c90c03e611

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/common/utils/tools/toolPolicy.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c90c03e611

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/agentSession.ts
Comment thread src/node/services/agentSession.ts Outdated
Comment thread src/node/services/historyScanner.ts Outdated
Comment thread src/node/services/agentSession.ts Outdated
Comment thread src/node/services/agentSession.ts Outdated
Comment thread src/browser/components/CompactionWarning/CompactionWarning.tsx
Comment thread src/node/services/agentSession.ts Outdated
…atches

Fail closed on unreadable reset candidates during initial history scans and
cursor append validation. Rotate the last durable boundary only after an
atomic batch publication, preserving non-fatal rotation failure semantics.

Cover malformed syntax/message shape, list/search/read privacy, append-stable
cursor invalidation, primed lazy rotation, active-only rewrites, request slices,
sequence ordering, and post-publication rotation failure with real history.
Honor regex denies through the standard last-match policy evaluator and seed
baseline history access before explicit policies. Resolve current agent policy
before rollover so restoration is not blocked by a stale availability claim.

Retain invoked skill snapshots on normal and emergency rollovers; emergency
retries reuse accepted snapshots without rerunning dynamic commands. Defer
restart warnings until settled memory permissions are known. Stabilize countdown
numerals and document the intentionally fail-closed pre-append cleanup tradeoff.

Validated red-green regressions, 1270 integrated tests, eight Storybook cases,
make static-check-full, and a final targeted/static pass after the last edit.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: $171.24_
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

Pushed 3ce1127e246b28e29642405f7e6cddbe8b347395 and replied individually to all eight findings. Seven received behavioral/UI fixes; the cleanup-order finding retains the accepted fail-closed D5 ordering with an earlier cancellation/admission check and an explicit ADR explanation of the tradeoff.

Validation: 1,270 integrated regressions, additional final targeted tests (including emergency skill reuse), eight Storybook cases, full static checks and a final make static-check all passed. Current desktop/phone screenshots confirm tabular countdown numerals; the phone recording also checks keyboard expansion.

Updated 375px countdown verification

Tabular rollover countdown at 375px

review-phone.webm

Generated with mux • Model: coder:openai/gpt-6-astra • Thinking: high

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ce1127e24

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/common/utils/compaction/contextBudget.ts Outdated
Comment thread src/node/services/agentSession.ts
Comment thread src/node/services/contextWindowRollover.ts Outdated
Comment thread src/node/services/agentSession.ts Outdated
Comment thread src/node/services/agentSession.ts Outdated
Comment thread src/node/services/agentSession.ts
Comment thread src/browser/features/RightSidebar/ThresholdSlider.tsx
Document the conservative allowance for omitted JSON structure/escaping and the in-process, post-commit restoration of only the copied snapshot's original file-tracking baseline.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1448.96`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=1448.96 -->

Signed-off-by: Thomas Kosiewski <tk@coder.com>
Merge pinned main 76f0ce3 onto the
validated parent f0f2562 without
rewriting either history. Resolve the sole AgentSession import conflict
by retaining both budget imports and main's AsyncLocalStorage import.

Preserve subscriber-local replay publication, observed engine ownership,
relay buffering, and router handoff from main alongside prepared admission,
ready-candidate cancellation, history privacy/display caps, structural JSON
accounting, and copied-file snapshot baselines from the branch.

Validation: 189 replay/router/store/coordinator tests plus 2,237 related
lifecycle/budget/history/subscription tests (2,426 total across 65 files);
both TypeScript projects; make static-check; make static-check-full;
git diff --check. No GitHub mutations or pushes.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

---
_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$721.28`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=721.28 -->
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@ThomasK33

Copy link
Copy Markdown
Member Author

JSON accounting, history recovery, and copied-file tracking

Published 74ed28b; replied to and resolved all four findings:

  • PRRT_kwDOPxxmWM6f8XWs: conservative allowance for omitted JSON structure and escape expansion, preserving real leaf encoding and semantic media exclusions. This is not exact serialized-token accounting.
  • PRRT_kwDOPxxmWM6f8XWx: in-process emergency retries restore only the copied snapshot's original canonical tracking baseline after a current-owner commit. Newer hashes and unrelated files are not substituted; no snapshot rereading or replay.
  • PRRT_kwDOPxxmWM6f8XW4: ordinary media-shaped JSON remains searchable/readable; validated media is omitted only in its semantic positions.
  • PRRT_kwDOPxxmWM6f8XW-: exhausted missing/stale item reads fail explicitly, while intermediate pages remain successful and resumable.

Also integrated the latest reconnect/replay ownership fix through a proper two-parent merge. The only textual conflict was the AgentSession import block; both sides were retained.

Local validation: 3,734 tests across 106 suites, both static gates, and independent safety/merge reviews passed. The recording is from this exact merged revision: real Node/tokenizer and disk-backed history, plus five real-file emergency-tracking cases with a controlled provider. Playback is accelerated; this is integration evidence, not live-provider reasoning.

Fresh CI and code/security reviews are pending.

Merged JSON accounting, history recovery, and file-tracking verification

json-tracking-merged.webm

Generated with mux • Model: coder:openai/gpt-6-astra • Thinking: high

Generated with xum • Model: coder:openai/gpt-6-astra • Thinking: high • Cost: $1456.53

@chatgpt-codex-connector

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74ed28b5fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/historyScanner.ts Outdated
Real same-size writes can retain identical nanosecond mtime/ctime values,
as reproduced in minimal Bun and Node probes. The unknown-write fixture
previously assumed that changing bytes guaranteed an observable stamp change.
Seed an old mtime before cursor creation, then assert changed content, equal
size and changed observed mtime before retaining the epoch-invalidation check.

This is a fixture correction, not a production detection fix. Identical fixed
stamps remain indistinguishable under the bounded O(1) provenance contract.
CI did not record stamps, so its individual collision cannot be proven.

Validation: 1,000 target repeats each on normal storage and tmpfs; 483 history
and privacy tests; make typecheck; scoped ESLint, Prettier and diff checks.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$332.91`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=332.91 -->
Address PRRT_kwDOPxxmWM6f-zNO by checking manual-reset privacy floors before
counting generic durable boundaries. Readable reset markers remain included,
but skip/fallback cannot cross them into older active or archived history.
Valid automatic rollovers and compactions remain skippable; malformed reset
evidence retains its existing exclusion behavior.

Validation: three red-first real-HistoryService regressions; all 28 provider
privacy tests and 486 broader history tests; make typecheck; scoped ESLint,
Prettier and diff checks. This production privacy fix is separate from the
preceding fixture-only timestamp clarification.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$344.67`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=344.67 -->
Real Node and Bun probes show same-size writes can retain all observed file stamps within one filesystem tick. Clarify that bounded receipts detect observable stamp changes, not content identity; stronger detection requires write isolation or whole-prefix verification. This does not change production detection behavior.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high`_

_Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$1488.17`_

<!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=1488.17 -->

Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@ThomasK33

Copy link
Copy Markdown
Member Author

Reset-floor privacy and provenance fixture clarification

Published 3e093e6 and resolved PRRT_kwDOPxxmWM6f-zNO.

Production privacy fix

Manual reset floors are evaluated before generic skippable boundaries. Older-window requests cannot cross a manual reset in chat or archive, including excessive skips and mixed rollover/compaction chains. Readable reset markers remain included; malformed evidence stays excluded; legal automatic boundaries remain skippable.

CI diagnosis and fixture-only correction

Minimal real-filesystem probes reproduced changed same-size content with identical device/inode/size/mtimeNs/ctimeNs: 190/500 Bun and 18/500 Node cases on tmpfs, without mocks. The CI failure had no stamp telemetry, so its individual cause cannot be proven directly.

The fixture now seeds an old mtime before cursor creation, performs the same real same-size rewrite, and checks changed bytes, equal size, and changed mtime before retaining the original epoch-invalidation assertion. 2,000 fixed repeats passed across normal storage and tmpfs. No production provenance logic, sleeps, added mocks, retries, or skips were introduced.

The ADR now explicitly states that a bounded stamp-only receipt cannot detect a rewrite whose observed metadata is unchanged. This is a documented limitation, not a production detection fix; stronger detection requires write isolation or whole-prefix verification.

Final local validation: 3,737 tests across 106 suites, both static gates, and independent safety review passed. The recording shows real-Node reset-skip checks, three history-floor cases, and 100 additional fixture repeats. Playback is accelerated. Production/test source is fe1f14d; the final commit adds the validated ADR clarification only.

Fresh CI and code/security reviews are pending.

Manual reset privacy and deterministic provenance fixture verification

reset-provenance-final.webm

Generated with mux • Model: coder:openai/gpt-6-astra • Thinking: high

Generated with xum • Model: coder:openai/gpt-6-astra • Thinking: high • Cost: $1488.17

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 3e093e62d9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

This comment has been minimized.

@ThomasK33
ThomasK33 added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 7c27339 Sep 7, 2026
20 of 21 checks passed
@ThomasK33
ThomasK33 deleted the plan-token-budget-combined branch September 7, 2026 18:03
ThomasK33 added a commit that referenced this pull request Sep 7, 2026
Merge main 7c27339 (#4097) into the compaction coordination branch. Preserve raw reset privacy boundaries and append provenance, guard token-budget single/batch admission, retain prepared request snapshots, and keep committed rollover ownership separate from cancellation retirement.

Signed-off-by: Thomas Kosiewski <tk@coder.com>

---

_Generated with `xum` • Model: `unavailable` • Thinking: `unavailable` • Cost: `$unavailable`_

<!-- mux-attribution: model=unavailable thinking=unavailable costs=unavailable -->

Change-Id: I321cbfd3af5c8466dea2834035c459e440b98827
ibetitsmike added a commit that referenced this pull request Sep 7, 2026
ibetitsmike added a commit that referenced this pull request Sep 7, 2026
…only by bash-monitor wakes

main (#4097) codifies that a late cancelSignal abort cannot revoke an accepted
send; the wake dispatch opts into withdrawal so a Stop landing during acceptance
or goal sync is still not followed by the wake's stream.
ethanndickson pushed a commit to Neppkun/xum that referenced this pull request Sep 8, 2026
## Summary

Bash-monitor attention no longer cuts an active turn. While the owner
workspace is busy (a queued or preparing turn, or a session-backed
stream), a monitor match is deferred through the existing
reconcile-after-idle path instead of being queued as a tool-end message,
and the wake itself is sent with `requireIdle`. The turn finishes in its
original stream and the agent answers on the same tool result; the owed
attention arrives as a separate wake once the workspace is idle, or is
withdrawn if the agent already consumed that output. A hard Stop retires
the attention that is currently owed while leaving the monitor armed.
Two independent fixes found along the way ride along: queue correlation
readers skip withdrawn entries, and aborted-stream usage is priced
against the effective (fallback) model.

Supersedes coder#4065, which is left open for its author to close.

## Background

coder#4065 addressed this incident: monitored background bash tasks settled
while the agent was streaming, the wake was queued as a tool-end
message, the stream was stopped at the next tool boundary
(`finishReason: "tool-calls"`), the agent's own `task_await` had already
consumed the output so the reconciler withdrew the wake, and the turn
was left stranded on an unanswered tool result until a human typed.
coder#4065 kept the mid-turn cut and added detection of stranded turns plus a
synthetic `[CONTINUE]` restart, a continuation lifecycle, a recovery
loop and caps (+1,563 net lines across 22 files).

This PR removes the cause instead: attention that the running turn can
consume must never be allowed to cut that turn. Production diff here is
+428 / -162 lines; the rest is tests.

## Implementation

- `WorkspaceService.dispatchBashMonitorWake`: when the owner has a
pending or preparing turn or a session-backed busy state, schedule
`scheduleBashMonitorWakeReconcileAfterIdle` and return `"deferred"`;
otherwise send the wake with `requireIdle: true` (no `queueDispatchMode:
"tool-end"`, no queue dedupe key, no abort listener that removed queued
entries). A wake that loses the race with a user send is skipped by the
existing requireIdle preflight and re-reconciled after idle, so manual
input is never held behind background attention.
- `WorkspaceService.interruptStream` with the new
`retireBashMonitorAttention` option (passed by the user Stop entry
points: the Stop button, the Escape keybind, the command palette Stop
and ACP `cancel`; internal interrupts such as goal promotion, archive,
ACP disconnect and send-now leave monitor output owed): before the
interrupt, `BashMonitorWakeReconciler.consumeCurrent` withdraws any
in-flight wake dispatch and marks the currently owed attention consumed,
so the abort's own idle transition has nothing to send and no wake fires
for output the user just stopped around. It runs before the abort
because `session.interruptStream` returns only after the abort settled,
when an idle-triggered dispatch may already be admitting; it does not
take `bashMonitorHistoryLocks`, so Stop never waits behind a wake
admission that holds the lock across preflight and stream construction.
Retirement is bounded to the frontier snapshotted when the Stop was
requested: output, settlements, monitor failures and newly armed
monitors that land while the Stop settles stay owed and wake normally
once idle. When the retirement or the abandon marker below cannot be
written, the stream is still aborted but `interruptStream` returns
`Err(STOP_UNRECORDED_MESSAGE)`; the reconciler keeps the retirement owed
and retries it before any later wake and on the next Stop. Every
renderer Stop goes through `stopStream()`, which publishes that error to
a workspace-keyed chat error store (`chatErrorToasts.ts`, replacing the
one-shot child-budget toast event) that the workspace's chat input
drains when it mounts, so the warning survives a workspace switch
mid-Stop. Stops that also opt out of auto-retry (Escape and Ctrl+C, both
barriers, compaction cancel, the palette command) pass
`disableAutoRetry` inside the same `interruptStream` call: the backend
starts the opt-out after `consumeCurrent` has withdrawn the pending wake
and reserved the reconciler lock (disabling retry releases the idle gate
a wake may wait behind), interrupts without waiting on its disk write,
and verifies it with the abandon marker before acknowledging.
`stopStream` also surfaces a transport rejection as the workspace chat
error. The ACP `cancel` settles the pending prompt as cancelled for that
sentinel too (the stream did stop) before reporting the durability
failure; `STOP_UNRECORDED_MESSAGE` lives in
`common/constants/workspace.ts` for that. Monitors stay registered and
later output still wakes. `consumeCurrent` becomes public; the
now-unread `dedupeKey` field is dropped from `BashMonitorWakeDispatch`.
- `AgentSession.sendMessage`: a cancelable wake whose signal fires after
the acceptance point of no return (its row is already durable) but
before PREPARING now resolves `Ok` without starting a stream, the same
contract as `cancelBeforeAcceptance` and the disposed path. Without
this, a Stop issued while the wake was in goal sync saw no turn to abort
and the wake started a stream after the Stop returned. The withdrawal is
opt-in (`withdrawAcceptedOnCancel`, set only by the bash-monitor wake
dispatch); every other cancelable send keeps the contract coder#4097
codified, that a late abort cannot revoke an accepted send. The
withdrawn wake records a startup auto-retry abandon marker against the
row actually persisted (the compaction request under on-send compaction)
so crash recovery does not replay it; `WorkspaceService.interruptStream`
joins the in-flight wake send and, if the marker write failed, retries
it (`recordPendingStartupAutoRetryAbandon`) before reporting the Stop
recorded. The auto-retry preference file is read once per session and
every reader and mutator of that state awaits the read; writes are
serialized and only the newest state change's completed write marks the
file recorded. A late load cannot overwrite a newer marker, an older
clear cannot land after a newer marker write and be acknowledged as
durable, and a write never rebuilds the file from unloaded defaults.
- `BashMonitorWakeReconciler` acceptance is durable-first: a cancelable
wake is accepted the moment its row is durable
(`AgentSession.prepareMessage` calls the idempotent `accept()` right
after `markRowsDurable()`, before goal sync), so a crash can never leave
a durable, unaccepted row. The accepted dispatch stays registered in the
reconciler until its send settles, so a Stop landing anywhere before the
stream starts still withdraws it. Consumption I/O (watermark, registry
row, acknowledgement) failing neither fails the send nor lets the signal
redeliver: it stays owed in reconciler state and is retried at the top
of the next reconcile pass, ahead of any dispatch. If that I/O keeps
failing until the app exits, the row is the only record of delivery, so
before dispatching, the reconciler looks outstanding signals of
processes older than the running instance (ages parsed, an unparseable
persisted age counts as older) up in the owner's transcript
(`listDeliveredBashMonitorWakes`: full history scanned newest-first,
compaction archive included, stopping once a chunk predates every
process being checked, memoized per outstanding key, malformed persisted
rows ignored) and consumes any wake the transcript already carries
instead of sending it again. A wake that triggered on-send compaction is
recognized through the compaction request row that carries it as
follow-up content. A failed history read holds dispatch in the retry
backoff. Owners that are archived or being archived hold their wakes
without an idle-retry loop (no session exists to wait on and
`sendMessage` refuses them); `unarchive` reconciles them once snapshot
restoration has succeeded, after lifecycle startup and even if a
follow-up step throws.
- `MessageQueue`: `hasAllWorkspaceTurnContinuations`,
`hasAllWorkspaceTurnContinuationsAheadOfPromotedToolEnd`,
`hasNextWorkspaceTurnContinuation`, `getNextQueueCutCandidate` and
`isNextEntryBashMonitorWake` now read the first entry whose cancel
signal has not fired (the rule `getNextDispatchableMode` already used),
so a withdrawn entry can neither supersede nor misattribute a delegated
turn's correlation; the visible queue badge and correlation revalidation
after a promotion read the same entry, and the raw FIFO-head reader is
removed.
- `StreamManager.cleanupAbortedStream` adds `model: streamInfo.model`
and the request-pinned `metadataModel` to the `stream-abort` metadata
(schema gains both as optional), and `AgentSession.handleTurnAbort`
prefers the effective model over the requested model string and passes
`metadataModel` into goal accounting, mirroring the stream-end path.
Usage of an aborted stream that fell back to another model is priced
against the model that actually ran, and a Coder runtime ID keeps its
pinned pricing identity instead of recording $0.

## Validation

- Producer-to-stream tests in `workspaceService.test.ts` drive a fake
SDK stream through `AIService.streamMessage` and assert: repeated owed
wakes during a turn never cut it and the answer arrives in the original
stream; unconsumed attention coalesces into one wake after natural
completion while idle attention starts promptly; a hard Stop retires
owed attention without disarming later idle wakes, completes while
another holder owns `bashMonitorHistoryLocks`, and returns
`STOP_UNRECORDED_MESSAGE` when retirement I/O fails while the retirement
still lands before any later wake; settlements, monitor failures and
monitor arms that happen after the Stop request stay owed; `stopStream`
retains an `Err` Stop for the workspace's chat input (shown on mount,
after unmount, not for another workspace, and one at a time until each
is dismissed) and stays silent on `Ok`; an unrecorded Stop still settles
the ACP prompt as cancelled while `cancel` reports the failure; an
interrupt without `retireBashMonitorAttention` keeps the attention owed;
a Stop issued during a wake's acceptance window leaves the session idle
with no stream and a later match still wakes; owed attention neither
holds a delegated completion open nor inherits its closed correlation; a
withdrawn idle wake rolls back its admission and a fresh delivery
succeeds.
- Red-green for the effective-model fix: removing the `metadata.model`
fallback or the `metadataModel` pass-through fails the new
`streamManager` and `agentSession.queueDispatch` cases (the Coder-ID
case asserts a non-zero cost). Red-green for the Stop ordering:
reinstating the lock-around-interrupt version times out the lock-holder
test.
- Remote UAT (Coder Agents on dogfood, `claude-sonnet-5` through the
gateway, xum built at c91286c and driven through the UI): PASS on all 10
scenarios. Three runs of a monitored task consumed in-turn by
`task_await` ended in the original stream with no wake; unconsumed
attention arrived as exactly one wake ~60 ms after the turn ended; an
idle match woke promptly; a user message typed during a turn dispatched
ahead of the wake and the wake followed that turn; Stop produced no wake
for the stopped output while the next match woke normally; 10 rapid
matches coalesced into one wake; reload kept the history intact. 7 wake
rows total, all accounted for; no `finishReason: "tool-calls"` turn was
left unanswered except the E1 turn the tester interrupted by hand, where
the queued user message backgrounded the running foreground bash and
dispatched at the tool boundary (existing behavior on `main`, not
touched here).

## Risks

Moderate, scoped to bash-monitor wakes and turn correlation.

- Behavior change: a monitor match during a long turn is reported after
the turn ends instead of at the next tool boundary. An agent that needs
the output mid-turn still gets it through `task_await`; it is only the
unsolicited wake that moves later.
- The hard-Stop path retires owed attention before the abort rather than
after it. Output that arrives during the few milliseconds the abort
takes to settle is treated as post-Stop output and wakes normally once
idle. `session.interruptStream` returns `Ok` even with no active stream,
so pressing Stop on an idle workspace retires its owed attention as
well; that matches the previous head. Only callers that pass
`retireBashMonitorAttention` retire attention, so a caller added later
without the option keeps the pre-PR behavior (output stays owed). A Stop
whose retirement or abandon marker cannot be written reports an error
(shown as a toast) instead of succeeding silently; the stream is still
stopped and nothing is lost, the dismissed output may wake once on the
next launch.
- The queue reader change only affects entries whose cancel signal has
already fired; those entries were already dispatched as no-ops.

## Pains

The branch was rebased across coder#4109 (turn lifecycle centralization),
which required porting the test harnesses from direct `aiEmitter` events
to settling `TurnStreamHandle.completion`. Locally, `bun` on PATH
resolved to 1.2.15 while the repo pins 1.3.5; under 1.2.15 the
injected-failure tests in `streamManager.test.ts` and
`agentSession.queueDispatch.test.ts` fail identically on `main`, which
cost a diagnosis round. Merging coder#4097 (token-budget rollovers) surfaced
a contract conflict: its tests assert that a late cancel cannot revoke
an accepted send, which is exactly what the wake withdrawal did; the
opt-in flag above reconciles the two.

---

_Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking:
`xhigh` • Cost: `$926.99`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=926.99 -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 9, 2026
## Summary

Version bump for the v0.28.5 patch release. Headline changes since
v0.28.4: remote server connections in the desktop app (coder#4101),
self-updating `xum server` under a restart supervisor (coder#4083, coder#4127),
first-class GPT-6 Astra and Astra Pro support including Codex OAuth
routing (coder#4064, coder#4094, coder#4106, coder#4124), token-budget context window
rollovers (coder#4097), the workspace remembering model and mode on send
(coder#3968), in-place plugin updates (coder#4164), the optional flat sidebar chat
list (coder#3994), and copying selected chat text as Markdown (coder#4170). It
also carries a long run of streaming, compaction, and task-lifecycle
fixes (reconnect streaming coder#4123, message edits during active streams
coder#4153, Codex OAuth prompt-cache routing coder#4159, compaction/history
fencing coder#4133 through coder#4148, task lock ordering coder#4161) plus the Effect
Wave 4 runtime refactors and deslop passes 1 through 3.

## Implementation

Bumped with `node ./scripts/set-package-version.js 0.28.5` so the root
`package.json` and the legacy `packages/mux-compat` forwarding package
stay version-locked. `src/common/compat/productIdentity.test.ts` passes
locally (8/8).

After this PR merges, the `v0.28.5` tag will be applied to the squash
commit and the GitHub Release published to trigger the
desktop/npm/docker pipelines.

---

_Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking:
`xhigh` • Cost: `$1.64`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=xhigh
costs=1.64 -->
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Sep 9, 2026
…oder#4156)

## Summary

Under the `tokenBudget` experiment, offer the agent one bounded **final
notes-flush step** between a mid-stream `rollover` decision and the
context reset, so the fresh window starts from the agent's own
`context-notes.md` hand-off instead of nothing. Also floor the advance
warning on small context windows so it fires at least
`WARNING_ADVANCE_MIN_TOKENS` (6 144) before the rollover point.

## Background

The only flush prompt today is the once-per-window advance warning
(threshold − 10 %). If the model is mid tool-chain and ignores it, the
next threshold crossing rolls over instantly — even with headroom. The
percent-based gap also shrinks with the window (≈ 4.8 k tokens at 32 k),
while a flush costs 2–4 k. Follows coder#4097.

## Implementation

- `evaluateStepBudget`: `flushOpportunity` now means *safe* opportunity
— `hardProjected + WARNING_RESERVE_TOKENS < hardCeiling` (real-encoding
tool tokens can exceed the chars/4 heuristic). The same check gates
`warn` vs `rollover`.
- `AgentSession.onContextBudgetStepSettled`: on a mid-stream `rollover`
with headroom, writable memory, `session_history` available, no prior
flush in this window and an empty queue, enqueue **two** sealed tool-end
continuations: a flush entry (self-describing via
`muxMetadata.contextBudgetFlush`) and the unconditional rollover
`Continue`. `pendingRollover` stays set, so the flush can never postpone
the reset; the second entry's tool-end dispatch bounds the flush turn to
one provider step.
- `prepareContextBudgetSend`: recognizes the flush entry *before* the
rollover logic (otherwise `pendingRollover` pre-empts it), re-checks
headroom/tool gates with on-send numbers, and emits a
`context-budget-warning` prefix row with `final: true`. If a gate no
longer holds, the entry degrades to a plain `Continue` and the flag is
stripped so nothing leaks into the fresh window.
- `contextBudgetFlushClaimed` is once-per-window, derived from history
with `||=` (restart/retry safe), cleared with the other budget state.
- Renderer: final rows use the same collapsible with summary **Context
window ending: notes flush**; Storybook `Rollover`/`Phone375` cover both
rows.
- Warning floor: `warnAt = min(percentRule, max(rolloverAt − 6144,
rolloverAt / 2))` with `rolloverAt = min(forceAt, hardCeiling)`. The
plan's "clamp ≥ 0" would have made 4 k/8 k windows warn on the first
request (existing small-model tests caught it), so the floor is clamped
to half the rollover point instead. `WARNING_ADVANCE_PERCENT` (shared
with legacy compaction) is untouched.
- Docs: `docs/workspaces/compaction/token-budget.md` describes both
prompts and every skip condition.

## Validation

Live dogfood in an isolated dev-server sandbox (Haiku 4.5,
`contextWindowTokens: 128000`, slider 50 %, Memory + Hot Set on).
`chat.jsonl` / `devtools.jsonl` confirm the exact sequence:

1. Advance warning at 77 842 tokens → agent `create`s
`context-notes.md`.
2. Settled step at 97.5 k → final flush row → the flush run is **one
provider step** with a single `memory str_replace` → **Context window
rollover** divider (`reason: mid-stream`, `flushOpportunity: true`).
3. Fresh window's first request: 2 messages, notes preloaded in the
system prompt; agent uses `memory view` + `session_history` and finishes
without re-reading completed files.

![Final flush prompt
row](https://github.com/user-attachments/assets/839b9d1a-0607-4ab5-9d38-135eb80a60df)

![Flush → single memory write → rollover divider → fresh
window](https://github.com/user-attachments/assets/c37722f5-8892-4df0-ac00-accb21f435f6)

![Advance warning row and the notes write it
triggered](https://github.com/user-attachments/assets/5ba84998-4bed-4be9-ba25-bbb15a8aea72)

![Experiments enabled in the
sandbox](https://github.com/user-attachments/assets/1cf8d036-d44f-46dd-bdef-279a8076d239)


https://github.com/user-attachments/assets/b90b8b16-5d5f-4fe1-903d-73499cda656a

Not dogfooded live: 32 k windows and the 100 % negative check — the exec
system prompt alone is ~34 k tokens for Haiku, so a 32 k window cannot
admit the first request. Both are covered by unit tests (`32_768 @ 70 %`
warns at 18 432; auto-disabled budget never warns/rolls over).

`agentSession.turnCompletion › late abort bookkeeping failure…` fails
identically on the base commit (pre-existing, unrelated).

## Risks

Medium, scoped to the opt-in `tokenBudget` experiment. The riskiest seam
is the queue: two sealed entries instead of one during the original
stream, and the dispatch-time re-check ordering in
`prepareContextBudgetSend`. Covered by tests for: headroom lost at
dispatch (degrades to `Continue`), user message queued before/after the
pair, removing the flush entry, text-only flush turns, interrupt/manual
reset clearing both entries, and once-per-window across restart. The
evaluator change can flip `flushOpportunity` to `false` slightly earlier
(reserve-aware), which only changes lead-in wording. Non-experiment
paths are untouched.

---

<details>
<summary>📋 Implementation Plan</summary>

# Token-budget context windows: offer a final notes-flush step before
rollover

## Goal

Under the `tokenBudget` experiment, make it far more likely that the
agent writes
`/memories/workspace/context-notes.md` before a context window is
sealed, so the fresh window
starts from the agent's own hand-off notes (preloaded by the memory hot
set) instead of nothing.

Net product LoC (recommended path, Phases 1+2): **≈ 60–75 lines** (Phase
1 ≈ 50–60 incl.
guards and types, renderer ≈ 4, Phase 2 ≈ 8). Tests and docs excluded.

## Current behaviour (verified in code, landed in PR coder#4097)

| Piece | Where | Behaviour |
| --- | --- | --- |
| Budget evaluation |
`src/common/utils/compaction/contextBudget.ts:70-126`
(`evaluateStepBudget`) | `warn` **once per window** when `projected >=
limit·(threshold−10%)` and `projected + WARNING_RESERVE_TOKENS(2048) <
hardCeiling`; `rollover` at `limit·(threshold+5%)` or at the hard
ceiling (`limit − min(8192, 25%)`); `block` only when threshold ≥ 1.
`flushOpportunity` on the rollover branch is `projected < hardCeiling`
(no reserve). |
| Mid-stream hook | `src/node/services/agentSession.ts:5682-5762`
(`onContextBudgetStepSettled`) | `warn` → `contextBudgetWarningClaimed =
true`, `pendingBudgetWarning = true`; `rollover` → `pendingRollover ??=
{…, reason: "mid-stream", flushOpportunity}`. Both queue one synthetic
`Continue` (`queueDispatchMode: "tool-end"`, sealed,
`removableDedupeKey`, dedupe key `CONTEXT_WARNING_DEDUPE_KEY` /
`CONTEXT_CONTINUE_DEDUPE_KEY`) only if the queue is empty. |
| Stream stop | `src/node/services/streamManager.ts:2416-2443` | Any
non-`continue` decision returns `true` from the AI-SDK stop condition →
stream ends cleanly at the step boundary; `block` throws. The stop
condition only runs after a step **with tool calls**. |
| On-send | `agentSession.ts:5520-5680` (`checkContextBudgetOnSend`) |
`pendingRollover` (or a fresh `rollover` decision) →
`createRolloverPrefix` (reset boundary + hidden lead-in) is appended
**immediately**; `warn`/`pendingBudgetWarning` →
`createContextBudgetWarning` prefix (`uiVisible`, `muxMetadata.type:
"context-budget-warning"`). `contextBudgetWarningClaimed` is re-derived
from history rows (`:5536`, `:6799`). |
| State reset | `agentSession.ts:4990-4999` (`clearContextBudgetState`)
| Bumps generation, clears
`pendingRollover`/`pendingBudgetWarning`/`contextBudgetWarningClaimed`,
removes both dedupe-key prefixes from the queue. Called on abort, manual
reset, and after a rollover is accepted (`:4425`). |
| Prompts | `src/node/services/contextWindowRollover.ts:37-93` |
`buildBudgetWarningText` ("write/update context-notes.md now … then
continue the current task"); `buildLeadInText` adds "The window filled
before a safe notes-flush opportunity." when `flushOpportunity` is
false. |
| Renderer |
`src/browser/utils/messages/displayedMessageBuilder.ts:397-405`,
`src/browser/features/Messages/MessageRenderer.tsx:101-109` | Warning
rows render as `CollapsibleMachineMessage` with summary "Context budget
warning". Story: `src/browser/stories/App.tokenBudget.stories.tsx`. |
| Preload | `src/node/services/memoryHotSet.ts:192-212` |
`context-notes.md` is force-included (≤ 8 KiB / 2000 tokens, truncated
not rejected) when the experiment is active. System prompt already
carries `<context-notes-guidance>` (`turnContextAssembler.ts:650-665`).
|
| Queue semantics | `src/node/services/messageQueue.ts:582-623` |
`sealed`/`removableDedupeKey` entries never coalesce: two `addOnce`
calls with different dedupe keys yield two entries dispatched in order.
The queue is not persisted across restart (docs: restart leaves the
workspace paused; the next send re-evaluates from history). |

## Gaps

1. **Single, early, soft nudge.** The only flush prompt is the advance
warning. If the model is
mid tool-chain and ignores it, the next threshold crossing rolls over
instantly — even when
`flushOpportunity` says there was headroom. Nothing gives a "last call".
2. **Relative-only advance.** 10 % of the window separates the warning
from the rollover point
(+5 %), i.e. a 15 % gap: 19 k tokens at 128 k, but only 4.8 k at 32 k
and 2.4 k at 16 k
(custom/Ollama models via `contextWindow` overrides). A flush costs ~2–4
k tokens.
3. **No evidence loop.** Nothing verifies the model actually writes
notes before rollovers.

## Design decision

**Chosen: add a bounded "final flush" step between the mid-stream
`rollover` decision and the
reset**, reusing the existing warning row type and queue machinery.
Rejected alternatives are in
the collapsible below.

Key properties:
- Mid-stream only (`onContextBudgetStepSettled`). On-send rollovers (a
human message arriving
above threshold) stay immediate: inserting a flush turn there would have
to park the user's
message and re-send it after the reset, which is a much larger change
for a rarer path.
- Offered **at most once per window**, only when memory is writable,
`session_history` is
available (so the reset that follows can actually be admitted and the
prompt's promise holds),
and there is real headroom (`projected + WARNING_RESERVE_TOKENS <
hardCeiling`) — re-checked
  with on-send numbers when the flush entry dispatches.
- Bounded to **one provider step** (not necessarily one tool call — a
step may carry sibling
tool calls): the follow-up rollover `Continue` is pre-queued with
`tool-end` dispatch, and the
flush turn's own step-settled evaluation returns `rollover`, so the
stream stops after its first
tool step either way (or at stream end if the model only emits text).
Cost per rollover: one
  extra request + one memory write.
- The rollover that follows is **unconditional** (`pendingRollover`
stays set), so the flush step
  can never postpone the reset.

<details>
<summary>Alternatives considered</summary>

- **Repeat the advance warning every N %** (~10 LoC). Simpler, but every
repeat interrupts the
stream and re-requests, still gives no definitive "last call", and the
rollover remains
  immediate. Weaker fit for the stated problem.
- **Prompt "your turn is ending" after the threshold (as literally
proposed).** Rollover does not
end the turn — the mid-stream case continues the same task in the fresh
window, and the current
lead-in relies on that. "Turn ending" wording invites the model to wrap
up or address the user.
Prompting only *after* the threshold also spends the last usable tokens
on housekeeping; the
flush must be offered while headroom exists, which the reserve check
enforces.
- **Mechanical hand-off note written by Xum** (last tool names, touched
files, original request;
~80–120 LoC). Deterministic and model-independent, but overlaps with
`session_history` (which
already gives the fresh window retrieval) and is a separate feature.
Deferred; revisit if
  dogfooding shows models still skip the flush.
- **New `flush` decision value from `evaluateStepBudget`.** Would ripple
through the
`OnStepSettled` union in `streamManager.ts:268-270` and
`agentSession.ts:5684` for no
behavioural gain; the pure evaluator lacks the state (`memoryWritable`,
"already offered") that
decides whether to offer a flush. Keep the decision set; let
`AgentSession` own the policy.
</details>

## Phase 1 — Final flush step before a mid-stream rollover (≈ 50–60 LoC)

### 1.1 Evaluator: `flushOpportunity` means *safe* opportunity
`src/common/utils/compaction/contextBudget.ts:113-124`
- Introduce `const safeFlush = Math.max(projected, hardProjected) +
WARNING_RESERVE_TOKENS < hardCeiling;`
(conservative: `hardProjected` uses real-encoding `toolResultTokens`,
which can exceed the
chars/4 heuristic). Use it for the rollover branch's `flushOpportunity`
**and** for the existing
`warn`-vs-`rollover` reserve check at `:121` (today that check uses
`projected` only).
- The lead-in sentence "filled before a *safe* notes-flush opportunity"
then matches the flag's
  meaning, and Phase 1 relies on the flag directly.
- Update `contextBudget.test.ts` expectations around the ceiling (e.g.
110 k / 128 k → `true`;
118 k → `false`; a case where `toolResultTokens` pushes `hardProjected`
over the reserve while
  `projected` alone would pass → `false`).

### 1.2 Message metadata
`src/common/types/message.ts:628-632`
- Extend the `context-budget-warning` member with `final?: true`
(advance warning = absent; final
flush = `true`). No zod change: `muxMetadata` is `z.custom`/`z.any` at
the boundary
(`orpc/schemas/stream.ts:279`, `orpc/schemas/message.ts:183`);
`contextWindows.ts` only validates
  rollover metadata.
- `src/common/types/message.ts:567`: add sibling flag
`contextBudgetFlush?: true` next to
`contextBudgetContinuation` so the queued flush entry is self-describing
(see 1.4).
- `isTokenBudgetInternalMessage` (`message.ts:807-815`) already covers
both the warning row and
synthetic continuations → `hasRolloverEligibleMessages` needs no change.

### 1.3 Prompt text
`src/node/services/contextWindowRollover.ts:60-93`
- `buildBudgetWarningText` / `createContextBudgetWarning` gain a `final:
boolean` argument. When
`final`, emit (only reached when memory is writable, so no fallback
wording is needed):

> Context window ~X% used (N of M tokens). This is the last step in this
context window: the
> next message starts a fresh provider context that does not carry this
transcript.
> `/memories/workspace/context-notes.md` stays available through the
memory tool and, when
> memory hot-set loading is enabled, is preloaded there if present
(bounded to 8 KiB);
> `session_history` can retrieve earlier messages. Write or update that
file now in a single
> `memory` call — essential state first: goal, decisions, invariants,
open tasks, blockers, and
> the exact paths/IDs needed to resume. If the file is already
preloaded, use
> `str_replace`/`insert`; otherwise `create`. Do not continue the task
or reply to the user in
  > this step.
- Set `muxMetadata: { type: "context-budget-warning", contextTokens,
maxTokens, final: true }`.

### 1.4 Session state machine
`src/node/services/agentSession.ts`

State (`:964-970`): add `private contextBudgetFlushClaimed = false;`
(once per window). No
`pendingBudgetFlush` — the dispatched entry identifies itself via
`contextBudgetFlush`.

`clearContextBudgetState` (`:4990-4999`): reset
`contextBudgetFlushClaimed`. The two existing
`removeByDedupeKeyPrefix` calls already drop both queued continuations.

History-derived claims (`:5536`, `:6799`): alongside
`contextBudgetWarningClaimed`, derive
`this.contextBudgetFlushClaimed ||= rows.some(type ===
"context-budget-warning" && final === true)`
so a restart or retry never offers a second flush in the same window.
Use `||=` (not `=`): the
in-memory claim set at offer time must not be reset by a history read
that runs before the final
row is durable. Known non-goal: a restart *before* the flush entry
dispatches loses the queued
opportunity (the queue is not persisted); the next send re-evaluates
from history and performs an
on-send rollover, exactly as today.

`onContextBudgetStepSettled` (`:5716-5760`), in the `rollover` branch
after the generation check:
```ts
const offerFlush =
  this.pendingRollover == null &&
  !this.contextBudgetFlushClaimed &&
  decision.flushOpportunity &&
  step.memoryWritable &&
  step.sessionHistoryAvailable &&
  this.messageQueue.isEmpty();
this.pendingRollover ??= { …existing…, flushOpportunity: decision.flushOpportunity };
if (offerFlush) {
  this.contextBudgetFlushClaimed = true;
  // Entry 1: the flush turn (hidden trigger text must not contradict the visible prefix).
  // Entry 2: the unconditional rollover; its tool-end dispatch also bounds the flush turn
  // to a single provider step.
  addOnce("Flush context notes now.", { …options, muxMetadata: { …turnMeta, contextBudgetContinuation: true, contextBudgetFlush: true } }, CONTEXT_WARNING_DEDUPE_KEY, internal);
  addOnce("Continue", { …options, muxMetadata: { …turnMeta, contextBudgetContinuation: true } }, CONTEXT_CONTINUE_DEDUPE_KEY, internal);
  emitQueuedMessageChanged(); return "rollover";
}
```
Fall through to the existing single-`Continue` enqueue otherwise.
`assert` that neither dedupe
key is already pending before adding the pair (impossible-state check).

`checkContextBudgetOnSend` (`:5598-5651`) — **ordering matters**:
compute `decision` as today,
then detect the flush dispatch **before**
`shouldRollover`/`createRolloverPrefix` can run,
otherwise `pendingRollover != null` (or the on-send `rollover` decision)
pre-empts the flush:
```ts
const isFlushEntry = options.muxMetadata?.contextBudgetFlush === true;
const recoveryAvailable =
  this.contextBudgetHistoryAvailable && !isSessionHistoryDisabled(options.toolPolicy);
const flushStillSafe =
  decision.hardCeiling !== undefined &&
  decision.projected + WARNING_RESERVE_TOKENS < decision.hardCeiling; // on-send has no hardProjected
if (
  isFlushEntry &&
  this.pendingRollover != null &&
  flushStillSafe &&
  this.contextBudgetMemoryWritable === true &&
  recoveryAvailable
) {
  // Keep pendingRollover: the next dispatch seals this window regardless of usage.
  return Ok({
    prefix: [createContextBudgetWarning(decision.projected, maxTokens, true, recoveryAvailable, /*final*/ true)],
  });
}
if (isFlushEntry) {
  // Degraded to an ordinary continuation (state cleared, headroom gone, memory read-only, or
  // recovery unavailable): neither the trigger text nor the flush flag may leak into the fresh window.
  userMessage.parts = [{ type: "text", text: "Continue" }];
  const { contextBudgetFlush: _dropped, ...rest } = userMessage.metadata.muxMetadata;
  userMessage.metadata.muxMetadata = rest; // omit the key; do not persist `undefined`
}
// …existing shouldRollover / rollover / warning logic unchanged…
```
- `this.contextBudgetMemoryWritable` / `contextBudgetHistoryAvailable`
are the settled values
from the previous stream (`:5690-5691`), a best-effort gate — do not
re-run tool assembly here.
An unknown value skips the flush rather than promising a write it cannot
make.
- Existing `:4429` (`contextBudgetWarningClaimed ||=
contextBudgetPrefix.length > 0`) already marks
  the window as warned once the flush prefix is appended; also set
`this.contextBudgetFlushClaimed ||= prefix is final` there so the claim
survives even if the
  step-settled path did not set it (retry/restart paths).
- During the flush turn, the existing code already does the right thing:
step-settled evaluates
`rollover` again, `pendingRollover ??=` is a no-op, the queue is
non-empty so nothing is added,
the stream stops; `sendQueuedMessages("terminal")` dispatches entry 2 →
`createRolloverPrefix`
→ fresh window with `flushOpportunity: true`. If the model replies with
text only (no tool
step, so the stop condition never runs), entry 2 still dispatches at
stream end.

### 1.5 Renderer (≈ 4 LoC)
- `displayedMessageBuilder.ts:397-405`: pass `final` through in
`contextBudgetWarning`.
- `MessageRenderer.tsx:101-109`: summary `final ? "Context window
ending: notes flush" : "Context budget warning"`; keep the same
`AlertTriangle` icon and marker.
- `App.tokenBudget.stories.tsx`: add the final-flush row (before the
rollover divider) to the
`Default` story so Pixel snapshots cover both summaries. Check the phone
viewport per the
Storybook rule in AGENTS.md (the collapsible already handles narrow
widths; no new breakpoint).

### 1.6 Tests (Phase 1)
`src/node/services/agentSession.tokenBudget.test.ts` (harness:
`setup()`, `step()`, `seedHistory()`,
`finishAndDispatch()`, `allRows()`, `rolloverRows()`):
- **Update** `test.each([110_000, 127_000])` at `:1254`: 110 k
(headroom) now yields a final
flush row then a rollover; 127 k (at ceiling) stays an immediate
rollover with
  `flushOpportunity: false` and no warning row.
- New: *settled rollover with headroom offers exactly one final flush,
then seals* — send →
`onStepSettled(step(110_000))` = `"rollover"` → `finishAndDispatch()` →
request 2 history has one
`context-budget-warning` row with `final: true`, **no** reset boundary;
`requests[1].onStepSettled(step(112_000))` = `"rollover"` → settle
stream 2 → request 3 has the reset boundary + lead-in, rollover metadata
`flushOpportunity: true`, the final row sits *before* the boundary, and
the continuation keeps delegated attribution (mirror `:1208-1252`).
- New: *text-only flush turn still rolls over* — settle stream 2 with
`finishReason: "stop"` and
  no step-settled call; request 3 must still carry the reset.
- New: *no flush when memory is read-only* (`step(110_000, {
memoryWritable: false })`), *when
session_history is unavailable* (`sessionHistoryAvailable: false`), and
*when a user message is
  already queued* — all roll over immediately as today.
- New: *user message queued behind the pair stays behind it* — queue a
user message after the
flush/rollover entries; assert the reset boundary precedes the user's
row and the user's row is
  the first non-internal message of the fresh window.
- New: *removing the flush entry*
(`removeByDedupeKeyPrefix(CONTEXT_WARNING_DEDUPE_KEY)`) →
  entry 2 rolls over directly, trigger text is `Continue`, no final row.
- New: *headroom re-check on dispatch* — seed usage so the on-send
projection exceeds
`hardCeiling − 2048`; the flush entry degrades to a plain rollover and
its trigger text is
replaced with `Continue` (no "Flush context notes now." row in the fresh
window).
- New: *flush is offered once per window* — inside the flush turn a
further `rollover` decision
  adds no second flush; after the reset, a later window may offer again.
- New: *restart after a final row* — new harness with the previous
`historyService`; the next send
rolls over without a second flush (`contextBudgetFlushClaimed` derived
from history).
- New: *abort during the flush turn* — `clearContextBudgetState` removes
both queued entries.
- `contextWindowRollover.test.ts`: `createContextBudgetWarning(...,
final: true)` sets `final`,
is `isTokenBudgetInternalMessage`, and is excluded by
`hasRolloverEligibleMessages`. Do not
  assert prompt prose (tautology rule).
- `StreamingMessageAggregator.tokenBudget.test.ts` /
`MessageRenderer.test.tsx`: `final`
  propagates and selects the ending summary.

### 1.7 Quality gate
`bun test src/node/services/agentSession.tokenBudget.test.ts
src/node/services/contextWindowRollover.test.ts
src/common/utils/compaction/contextBudget.test.ts
src/browser/utils/messages/StreamingMessageAggregator.tokenBudget.test.ts
src/browser/features/Messages/MessageRenderer.test.tsx`
then `make typecheck lint`. Then run Dogfood A (below) before starting
Phase 2.

## Phase 2 — Absolute floor on the warning advance (≈ 8 LoC)

`src/common/constants/contextBudget.ts`: `export const
WARNING_ADVANCE_MIN_TOKENS = 6_144;`
(three `WARNING_RESERVE_TOKENS`: one flush plus roughly two working
steps; tunable — document the
rationale in a comment).

`contextBudget.ts:113-118`: compute the *actual* rollover boundary once
—
`forceAt = limit·(threshold+5%)/100; rolloverAt = Math.min(forceAt,
hardCeiling)` — because on
small windows or high thresholds the hard ceiling, not the force buffer,
is where the window
really ends. Warn at `Math.min(limit·(threshold−10%)/100, rolloverAt −
WARNING_ADVANCE_MIN_TOKENS)`,
clamped ≥ 0. The existing reserve check (`projected + 2048 <
hardCeiling`) still gates `warn`
vs `rollover`.
Do **not** touch `WARNING_ADVANCE_PERCENT` in
`autoCompactionCheck.ts:63` — it is shared with the
legacy summary-compaction warning and
`CompactionMonitor.checkBeforeSend`.

Why it still matters after Phase 1: on small windows the rollover point
coincides with the hard
ceiling (32 768 @ 70 %: both 24 576), so `flushOpportunity` is false and
the final flush never fires;
the advance warning is the only chance there.

Tests (`contextBudget.test.ts`, limits as round literals):
- 128 000 @ 70 %: unchanged, warns at 76 800 (percent rule wins; floor
irrelevant).
- 32 768 @ 70 %: `hardCeiling = 32 768 − min(8 192, 8 192) = 24 576`;
`rolloverAt = min(24 576, 24 576)`
→ warns at 18 432 instead of ≈ 19 661. (Mind the 25 % cap in
`getContextBudgetHardCeiling`:
for a 32 000 literal the reserve is 8 000, so the ceiling is 24 000, not
23 808.)
- 64 000 @ 95 %: `rolloverAt = min(64 000, 55 808) = 55 808` → warns at
49 664 instead of 54 400
(proves the hard ceiling, not the force point, anchors the floor; 128
000 @ 95 % would *not*
  exercise this because the percent rule still wins there).
- Tiny limit (e.g. 4 000): warning threshold clamps to 0, never
negative; `warn` is still suppressed
when the reserve does not fit, so the outcome is `rollover`, not an
early `warn`.

## Phase 3 — Docs

`docs/workspaces/compaction/token-budget.md`, section *Keeping useful
context*: describe the two
prompts (advance warning "then continue"; final flush "one memory call,
then the window is
sealed"), when the final flush is skipped (read-only memory,
`session_history` unavailable, no
headroom, user message already queued, on-send rollovers, restart before
dispatch), the one-step
bound, and the absolute floor. Keep the "opportunity, not
a guarantee" caveat. No `docs.json` change (existing page). Optional
one-line amendment in
`docs/adr/0005-token-budget-context-windows.md`.

Final gate: `make static-check` (includes docs link check) + the Phase
1/2 test set.

## Dogfooding

### Setup (once)
1. `agent_skill_read dev-server-sandbox`; start an isolated server (temp
`XUM_ROOT`, free port).
Confirm a real provider key is available in the environment (Coder may
inject bridge
`*_BASE_URL` + token pairs — pair them correctly; see memory gotchas).
2. In Settings → Experiments enable **Token-budget context windows**;
ensure **Memory** and
**Memory Hot Set** are enabled; set the context-usage slider to **50 %**
for the test model.
3. Shrink the window: add a custom model entry with `contextWindow:
64000` for the test model
(`getModelContextWindowOverride`,
`src/common/utils/providers/modelEntries.ts:33`). At 64 k /
50 %: warning ≈ 25.6 k, rollover ≈ 35.2 k, ceiling 55.8 k → the final
flush path is reachable.
Implementer: confirm the exact config shape in Settings → Providers
before relying on it.
4. Enable API debug logs so `~/<XUM_ROOT>/sessions/<ws>/devtools.jsonl`
captures requests.

### Dogfood A (after Phase 1)
Prompt a workspace with a context-hungry task (e.g. "read every file
under
`src/common/utils/compaction` and
`src/node/services/contextWindowRollover.ts`, then list every
exported symbol"). Using `agent-browser` (`open` → `snapshot -i` →
`screenshot`), capture:
1. The advance-warning collapsible row ("Context budget warning").
2. The final-flush row ("Context window ending: notes flush") followed
by a single provider step
containing a `memory` write to `context-notes.md` (sibling calls in that
step are allowed, a
   second step is not), then the **Context window rollover** divider.
3. The agent continuing the task in the fresh window without
re-executing completed reads.
4. `memory view /memories/workspace/context-notes.md` (or the file on
disk under the session dir)
shows the notes with a fresh mtime; `devtools.jsonl` shows the fresh
window's first request
   containing the notes excerpt.
Negative check: temporarily set the slider to 100 % → no warning, no
flush, hard checks only.
Record a short video of the full sequence (agent-browser recording if
available; otherwise
sequential screenshots) and `attach_file` the artifacts.

### Dogfood B (after Phase 2)
Set `contextWindow: 32768` at 70 %: verify the advance warning now
appears around 18 432 tokens
(devtools usage) instead of ≈ 19 661, and that the rollover still
happens at the 24 576 ceiling;
confirm no behaviour change at the default 200 k window.

## Acceptance criteria
- With headroom, writable memory and `session_history` available, every
mid-stream rollover is
preceded by exactly one `final` warning row and at most one provider
step (sibling tool calls
  allowed, no second step); the reset follows unconditionally.
- No final flush when memory is read-only, when `session_history` is
unavailable, when
`projected + 2048 ≥ hardCeiling` (at settle time or re-checked at
dispatch), when a user message
is already queued, on on-send rollovers, or twice in one window
(including after restart).
- A text-only flush turn still rolls over; abort clears both queued
continuations.
- Queue invariants: a user message queued after the pair stays behind it
and lands in the fresh
window after the reset; removing the flush entry lets the rollover entry
seal the window; removing
the rollover entry leaves `pendingRollover` set so the next send resets.
The flush trigger text
  never appears in a fresh window.
- Advance warning fires at least `WARNING_ADVANCE_MIN_TOKENS` before the
rollover point on small
windows; unchanged on ≥ 128 k windows; legacy compaction warning
unaffected.
- All listed tests pass; `make static-check` green; dogfood evidence
attached.

## Risks / notes for the implementer
- `addOnce` inside `onContextBudgetStepSettled` runs while the stream is
active but outside any
queue drain; verify `backgroundProcessManager.setMessageQueued` reflects
the new tool-end entry
  (compare `agentSession.ts:9174-9177` and `emitQueuedMessageChanged`).
- The flush turn's `checkAssembledRequestBudget` preflight can still
reject if estimates drift;
that yields the existing "paused" behaviour and the next send rolls over
— acceptable, same as
  today's `warn` path. Do not add retries.
- If the model `create`s over an existing notes file the write fails and
the turn ends; the prompt
steers to `str_replace`/`insert` when preloaded. If dogfooding shows
this often, a follow-up can
allow a second step (requires a step counter in session state — out of
scope here).
- Queue UI shows two queued entries ("Flush context notes now." and
"Continue") during the
original stream; both are removable by the user exactly like today's
single entry, with the
  invariants listed under Acceptance criteria.
- Verify nothing keys on the literal `"Continue"` trigger text of budget
continuations
(`rg '"Continue"' src/node/services/agentSession.ts`) before changing
entry 1's text; the
`:5646-5649` replacement path is the only known consumer and stays
as-is.

</details>

---

_Generated with `xum` • Model: `anthropic:claude-fable-5-1` • Thinking:
`high` • Cost: `$44.07`_

<!-- mux-attribution: model=anthropic:claude-fable-5-1 thinking=high
costs=44.07 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant