Skip to content

compaction: stays on "Compacting" with no feedback when the provider fails or only sends SSE keepalives #61346

Open

Description

@zhejingxu

Description

A manual /compact sat on "Compacting" with an empty summary for over 90 minutes. There was no error and no retry notice. I had to call /api/session/:id/interrupt to get the session back. The provider was returning 503 no eligible upstream account is currently available.

I traced this to two causes on dev:

  1. Compaction retries are silent. In packages/core/src/session/compaction.ts (deliver, around L619-L686), retryable provider errors go through SessionRunnerRetry.policy(...) and then Effect.sleep(decision.delay). Normal steps use SessionRunnerRetry.make(...).wait(...), which publishes SessionEvent.RetryScheduled. Compaction publishes nothing and logs nothing, so up to 10 retries look the same as a frozen stream.

  2. SSE keepalives reset the 15-minute chunk timeout. In packages/ai/src/route/transport/http.ts (L119-L125), Stream.timeoutOrElse (DEFAULT_CHUNK_TIMEOUT_MS = 900_000) wraps the raw byte stream before framing.frame(...). sseFraming drops : keepalive comments later, but each one has already reset the timer. I checked this with a test next to packages/ai/test/http-timeout.test.ts: one data: event, then silence, fails with "Timed out waiting for response data". One data: event, then : keepalive comments, has still not finished after 20 virtual minutes.

#43618 fixed this for the old aisdk.ts path, and #43519 was closed. The V2 transport has the same problem. #54544 adds logging to the step retry path, but it does not cover compaction.

Possible fix: apply the chunk timeout after framing, so it times protocol frames instead of bytes, and publish retry status from compaction.

Plugins

None needed to reproduce

OpenCode version

2.2.5 (dev @ 1bf08ae)

Steps to reproduce

  1. Configure a provider whose endpoint either returns HTTP 503, or sends one SSE data: event and then only : keepalive comments.
  2. Run /compact in any session.
  3. The compaction row stays on "Compacting". With 503 there is no retry notice until all retries are used. With keepalives it never times out.

Operating System

macOS 26.5.1

Terminal

Web app (packages/app)

Activity

  1. opencodejr commented on Nov 3, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced the keepalive part on v2.2.5 and on the latest dev (3ba597f).

    The script below is a transport test in packages/ai. It sends one SSE data: event and then advances a test clock 20 virtual minutes:

    • Silence after the event: the request fails with "Timed out waiting for response data", as expected.
    • A : keepalive comment every minute: the request is still running after 20 minutes. The 15-minute chunk timeout in packages/ai/src/route/transport/http.ts is applied to the raw bytes before framing, so the comments keep resetting it.

    I confirmed the silent retries by reading the code; I didn't run a 503 case. deliver in packages/core/src/session/compaction.ts waits with Effect.sleep between retries and never publishes SessionEvent.RetryScheduled, which normal steps publish through SessionRunnerRetry.make().wait.

    Related: open PR #52866 moves the chunk timeout onto framed events, and #53747 reports steps hanging without a timeout.

    Reproduction script (keepalive-timeout.test.ts)
    // Reproduction for anomalyco/opencode#61346 (chunk timeout reset by SSE keepalive comments).
    // Copy into packages/ai/test/ of an opencode checkout and run:
    //   cd packages/ai && bun test test/keepalive-timeout.test.ts
    import { describe, expect } from "bun:test"
    import { Deferred, Effect, Fiber } from "effect"
    import * as TestClock from "effect/testing/TestClock"
    import { ai } from "../src/effect.js"
    import { LLMClient } from "../src/route.js"
    import { configure } from "../src/providers/openai.js"
    import { dynamicResponse } from "./lib/http.js"
    import { deltaChunk } from "./lib/openai-chunks.js"
    import { sseRaw } from "./lib/sse.js"
    import { it } from "./lib/effect.js"
    
    const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4.1-mini")
    const SSE = { headers: { "content-type": "text/event-stream" } }
    const encoder = new TextEncoder()
    
    // Sends one `data:` event, then hands the controller to the test so it can push keepalive comments.
    const server = Effect.gen(function* () {
      const ready = yield* Deferred.make<ReadableStreamDefaultController<Uint8Array>>()
      const layer = dynamicResponse((input) =>
        Effect.sync(() =>
          input.respond(
            new ReadableStream<Uint8Array>({
              start(controller) {
                controller.enqueue(encoder.encode(sseRaw(`data: ${JSON.stringify(deltaChunk({ content: "Hi" }))}`)))
                Deferred.doneUnsafe(ready, Effect.succeed(controller))
              },
            }),
            SSE,
          ),
        ),
      )
      return { layer, ready }
    })
    
    const run = (keepalive: boolean) =>
      Effect.gen(function* () {
        const s = yield* server
        const fiber = yield* LLMClient.generate(ai.request({ model, prompt: "Hello" })).pipe(
          Effect.provide(s.layer),
          Effect.flip,
          Effect.forkChild({ startImmediately: true }),
        )
        const controller = yield* Deferred.await(s.ready)
        // 20 virtual minutes, one keepalive comment per minute (or silence).
        for (let i = 0; i < 20; i++) {
          if (keepalive) controller.enqueue(encoder.encode(": keepalive\n\n"))
          yield* Effect.promise(() => new Promise((r) => setTimeout(r, 5)))
          yield* TestClock.adjust("1 minute")
          yield* Effect.yieldNow
        }
        return fiber.pollUnsafe()
      })
    
    describe("#61346 chunk timeout vs SSE keepalives", () => {
      it.effect("control: data then silence times out within 20 minutes", () =>
        Effect.gen(function* () {
          const exit = yield* run(false)
          console.log("silence ->", exit ? JSON.stringify(exit) : "still running")
          expect(exit).toBeDefined()
        }),
      )
    
      it.effect("data then only keepalives should also time out within 20 minutes", () =>
        Effect.gen(function* () {
          const exit = yield* run(true)
          console.log("keepalive ->", exit ? JSON.stringify(exit) : "still running after 20 virtual minutes")
          expect(exit).toBeDefined()
        }),
      )
    })

    Run it: bun keepalive-timeout.test.ts

  2. deosiree commented on Nov 3, 2026

    @deosiree

    I found the earlier closed PR #50757 covering the same compaction retry notice behavior. To avoid duplicating that work, I'm withdrawing this claim and will not submit another PR for this scope.

  3. rome-xi commented on Nov 3, 2026

    @rome-xi

    I can reproduce the keepalive part on dev (3ba597f): the chunk timeout wraps the raw byte stream before SSE framing, so : keepalive comments keep resetting it. I'd like to take that part (transport only, not the compaction retry notice) with a test in http-timeout.test.ts.

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