Skip to content

Session warming misses the cache it is meant to keep, and warms providers that don't need it #61775

Open

Description

@ryanskidmore

Description

We measured warming on a large sample of real v2 sessions (usage and timing metadata only). On Anthropic it roughly breaks even, and on OpenAI and Gemini it loses money. The causes are still present on dev:

  1. The warm isn't the step's request. session.generate rebuilds the request. It runs the generate hook instead of context, so a goal reminder or any plugin that only hooks context is missing. It previews history only up to the first unsettled assistant message, uses a different output limit, and appends the keep-alive prompt. In about 1 in 5 Anthropic warm sequences, the first warm missed the step's cached prefix and rewrote the whole prompt, usually because the system prompt or tools differed. Those sequences recovered about 5% of their cost; the rest returned about 2×.
  2. The interval runs from the end of the response, but the cache lifetime starts when the prompt is read. First-warm misses went from about 5% after short steps to over half after steps longer than 2 minutes. The flat 4 minutes also warms sooner and more often than 90% of a 5-minute TTL needs.
  3. Results are ignored. After a miss, it keeps warming an entry the next step can't use. Idle sessions are warmed for the full 30 minutes, although the chance the user comes back in the next interval falls from about 25% after the first warm to about 5% by the fifth. Break-even for a 5-minute Anthropic entry is about 9%.
  4. It warms implicit caches. Unwarmed OpenAI requests still read 80–89% of the prompt from cache after 5–30 minutes idle. Warming recovered about a tenth of its cost on OpenAI and about a sixth on Gemini.

I'm working on a fix: resend the step's exact request with a one-token output limit, warm only routes that set cache markers, time from when the request was sent, stop after a miss, and shorten the idle window.

Plugins

None needed

OpenCode version

2.0.x to 2.2.5, and current dev

Steps to reproduce

  1. Set "warming": true, use an Anthropic model, and set a goal.
  2. Send a prompt and wait for the warm.
  3. Compare the "session generation usage diagnostic" log with the step's usage: the warm reads little from cache and writes most of the prompt.
  4. Separately, send a prompt with a response that takes over a minute to stream, then go idle. The first warm misses.

Operating System

macOS, Linux

Terminal

Various

Activity

  1. opencodejr commented on Nov 4, 2026

    @opencodejr
    Contributor

    Thanks for the detailed write-up. I reproduced the timing problem (point 2) on 2.2.5 and on the latest dev (bd420e0).

    What I did, using the simulated model:

    • Set warming: { interval: "10 seconds", duration: "1 minute" }, standing in for the default 4 minutes.
    • Sent one prompt whose response streams for about 15s.
    • Logged when each request was sent.

    The step request went out at 1.7s and the response finished at 16.7s. The warm went out at 26.7s, which is 25s after the step's request. So the interval runs from the end of the response, not from when the prompt was read. With the default 4 minutes and a 5-minute TTL, any step streaming longer than about a minute makes the first warm arrive late. With "interval": "auto", the warm is late whenever the step takes more than 10% of the TTL. The script below reproduces it.

    On point 1:

    • In a plain session the warm request had the same system prompt, tools and history prefix as the step's request. Only the output limit differed, and the keep-alive prompt was added at the end.
    • The goal case I confirmed in the code but didn't drive end to end. The goal reminder is added only in the context hook, while the warm goes through generate. So whenever the step carries the reminder (for example after compaction), the warm's prefix differs.

    I couldn't measure real cache hit rates without a provider account. Stopping after a miss, a shorter idle window and skipping implicit-cache providers would change how warming is designed, so maintainers will need to weigh those separately.

    Related: #43628 (Gemini/Vertex misses with the 4-minute interval), #60969 ("auto" interval) and #61724 / #61737 (one warming loop per session).

    Reproduction script (drive.ts)
    // Shows when session warming fires relative to the step's model request, and diffs the two requests.
    // Config: warming interval 10s (stands in for the default 4 minutes). The step's response streams for ~15s.
    // Run: OPENCODE_DRIVE_MEDIA_DIR=/workspace/media opencode-drive run drive.ts   (OC_DEV=<checkout> to run from source)
    import { Effect, Stream } from "effect"
    import { OpenCodeDriver, Llm } from "opencode-drive"
    import { writeFileSync } from "node:fs"
    
    const log: Array<{ at: number; event: string; body?: any }> = []
    const start = Date.now()
    const mark = (event: string, body?: unknown) => log.push({ at: (Date.now() - start) / 1000, event, body })
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        config: { autoupdate: false, warming: { interval: "10 seconds", duration: "1 minute" } },
        tui: { recording: true },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          let step = false
          yield* llm.serve((request) => {
            const body = request.body as any
            if (!body?.tools) return Stream.make(Llm.text("Title"))
            if (!step) {
              step = true
              mark("step request sent", body)
              // A long-streaming step response: ~15 seconds.
              return Stream.make(Llm.text("Step answer part 1. "), Llm.pause(15_000), Llm.text("Step answer done.")).pipe(
                Stream.concat(Stream.fromEffect(Effect.sync(() => mark("step response finished"))).pipe(Stream.drain)),
              )
            }
            mark("warm request sent", body)
            return Stream.make(Llm.text("OK"))
          })
          yield* ui.submit("Say hello")
          yield* Effect.sleep(32_000)
          const out = process.env.LOG ?? "/workspace/repro/log.json"
          writeFileSync(out, JSON.stringify(log, null, 2))
          for (const e of log) console.log(`${e.at.toFixed(1)}s  ${e.event}`)
          const [s, w] = log.filter((e) => e.body).map((e) => e.body)
          if (s && w) {
            console.log("warm - step request gap:", (log.find((e) => e.event === "warm request sent")!.at - log[0]!.at).toFixed(1), "s")
            console.log("system/tools identical:", JSON.stringify(s.messages[0]) === JSON.stringify(w.messages[0]), JSON.stringify(s.tools) === JSON.stringify(w.tools))
            console.log("max tokens: step", s.max_completion_tokens, "warm", w.max_completion_tokens)
            console.log("warm tail:", JSON.stringify(w.messages.at(-1)).slice(0, 160))
          }
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. houleiyx commented on Nov 5, 2026

    @houleiyx

    The timing repro is useful. For the proposed fix, I’d also check one real Anthropic session with a goal set: capture the normal request, its warm request, and
    the next normal request, then compare their system prompt, tools, cache breakpoints, and cache read/write usage. That should show whether the warm actually
    preserves the prefix the next step uses.

    I work on ccglass. If the provider’s base URL can be pointed through its local proxy, its request diff and usage view
    can help with this check. A redacted comparison of those three requests would be more useful here than sharing the full prompt.

  3. cris005 commented on Nov 6, 2026

    @cris005

    I maintain a plugin that does its own keepalive (opencode-cache-manager), and it already handles the points you listed, so it may be a useful reference for the fix:

    • The ping is aligned in the http.request hook. Its message history is replaced with the exact messages of the session's last request, cache_control included, so anything a context hook added to the history is in the ping too.
    • The timer runs from when the last request was sent, not from the end of the response.
    • A ping counts as a hit if it reads at least 90% of the previous context from cache. If one misses, it stops pinging that session until the next real request, so a miss costs one ping.
    • It stops after a set number of pings per idle stretch (5 by default), and it skips sessions under a minimum context size (50k tokens by default). Both are configurable per session.
    • It only pings models it sets breakpoints on itself: Claude, and GPT-5.6+ with explicit breakpoints. It doesn't ping other models at all.

    It also keeps a per-session savings ledger in the sidebar. It weighs what the pings cost, wasted ones included, against the rewrites they avoided when you came back, so you can see whether keepalive is paying off on that session and turn it down or off if it isn't.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions