Skip to content

GET /session/status can report a session busy for a short window (and occasionally indefinitely) after its prompt stream has already closed #35472

Description

@barchett

Environment

  • OpenCode server: opencode serve (HTTP API mode)
  • SDK: @opencode-ai/sdk 1.14.25 (npm) — same client surface on current latest (1.17.x); the relevant server code (packages/opencode/src/session/run-state.ts, packages/opencode/src/session/status.ts) is unchanged in structure as of this writing
  • Client: a plain Node.js/TypeScript client using @opencode-ai/sdk's generated client, calling session.prompt() then session.status() directly over HTTP — no TUI, no desktop app, no SolidJS store in the loop
  • OS: reproduced on Linux (WSL2); not OS-specific

Observed Behavior

After client.session.prompt({ path: { id: sessionID }, ... }) resolves (i.e., the request/response for the prompt has completed and the assistant's final message is already persisted and readable via session.messages()), an immediate follow-up call to GET /session/status (via client.session.status()) can still report that session as { type: "busy" }.

In the common case this clears within roughly one to two seconds. In rarer cases (not yet minimally reproduced, but observed repeatedly in production use over weeks of operation) the session stays busy in the status map indefinitely — with no further model activity and no way to distinguish "still working" from "done, but the status map hasn't been told."

Root cause (from reading the server source)

  • packages/opencode/src/session/status.ts's SessionStatus.Service is a simple in-memory Map<SessionID, Info>. Idle sessions are actively deleted from the map (status.set() calls data.delete(sessionID) when the new status is idle); busy sessions are just written. GET /session/status reflects this map directly — an absent entry means idle, a present entry means whatever state was last written.
  • The only place that writes idle back into that map for a normal run is packages/opencode/src/session/run-state.ts, inside SessionRunState's internal Runner:
    const next = Runner.make<SessionV1.WithParts>(data.scope, {
      onIdle: Effect.gen(function* () {
        data.runners.delete(sessionID)
        yield* status.set(sessionID, { type: "idle" })
      }),
      onBusy: status.set(sessionID, { type: "busy" }),
      onInterrupt,
    })
    onIdle fires when the internal Runner abstraction decides the run has settled — which is a separate, asynchronous signal from "the HTTP response body for session.prompt() finished streaming to the caller." There is a real gap between the two: the caller can observe the prompt as complete (response closed, final assistant message persisted and queryable) before onIdle has fired and updated the status map.
  • This is architecturally distinct from — but produces an identical externally-visible symptom to — the desktop app's known stale-busy bug (#17657, fix proposed in #32128): that bug is in the SolidJS client store failing to reconcile a setStore call, purely client-side. The behavior described here is server-side — the raw HTTP response of GET /session/status itself lags the true completion state, before any client store is involved.
  • A related, previously-proposed server-side fix along these lines was PR #14222 ("fix(session): concurrent prompt race, leaked callbacks, stale busy status"), which included moving the idle transition to "right after the while loop" instead of relying on post-loop prune() + stream read. That PR was closed without being merged (no comment explaining why), so as far as we can tell the underlying lag is still present.

Expected Behavior

Once a session.prompt() (or session.command()) call has returned to the caller with a completed result, GET /session/status for that session should reflect idle on the very next poll — with no unbounded window where the two disagree. At minimum, the busy-clearing signal (onIdlestatus.set(..., { type: "idle" })) should never be able to lag indefinitely behind the response stream closing; today there appears to be no upper bound or recovery path if the onIdle callback is delayed or never fires.

Reproduction Sketch

  1. opencode serve --port 4096
  2. Create a session: POST /session (or SDK client.session.create())
  3. Send a prompt: client.session.prompt({ path: { id: sessionID }, body: { parts: [...] } }) and await its resolution
  4. Immediately call client.session.status() and inspect the entry for sessionID
  5. Observe: the entry is frequently still { type: "busy" } for a short window after step 3 resolved, even though client.session.messages({ path: { id: sessionID } }) already shows the completed assistant message
  6. (Harder to reproduce on demand) Under sustained/heavier use, the same entry can remain busy indefinitely with no further activity on the session

We do not yet have a minimal, deterministic repro for the "indefinite" case — only for the short (sub-2s) lag, which is consistently reproducible.

Impact

Any external orchestrator built directly on the HTTP API (not going through the TUI/desktop client) that uses GET /session/status as its source of truth for "is this session done yet" cannot trust a single poll immediately after a prompt completes. In practice this forces downstream tools into one of:

Both are compensating for the server not guaranteeing that the status map is consistent with the actual run state by the time a caller can observe it.

Real-world evidence — two independent workarounds in a downstream MCP server

Legate (an MCP server that wraps @opencode-ai/sdk to expose OpenCode sessions as Claude Code tools) currently carries two separate workarounds for this exact behavior, both in src/index.ts:

  1. legate_run (around lines 334–345): after a prompt resolves, the tool polls session.status() for up to 2 seconds (250ms interval) purely so that an immediate follow-up status check from the caller doesn't see a stale busy:

    // Grace delay: OpenCode's status map updates asynchronously after the stream closes.
    // Poll for up to 2s so that a legate_session_status call immediately after legate_run
    // returns sees idle rather than a stale busy.
    try {
      const graceDeadline = Date.now() + 2000;
      while (Date.now() < graceDeadline) {
        const { data } = await getClient(serverUrl).session.status({ query: dir ? { directory: dir } : undefined });
        const statusEntry = (data as Record<string, { type: string }> | null)?.[sessionId];
        if (!statusEntry || statusEntry.type !== 'busy') break;
        await new Promise<void>((r) => setTimeout(r, 250));
      }
    } catch { /* best-effort — never block the return on status lag */ }
  2. legate_await (around lines 1329–1355): a polling loop that waits for a dispatched session to go idle escapes after 5 consecutive busy polls if the last message in the session is from the assistant, on the theory that the status map has simply failed to catch up:

    // Stuck-busy escape: if status stays "busy" for STALE_BUSY_THRESHOLD consecutive polls,
    // cross-check against message history. If the last message is from the assistant the
    // agent has finished but OpenCode's status map has not caught up — break and treat as idle.
    const STALE_BUSY_THRESHOLD = 5;
    ...
    if (statusEntry.type === 'busy') {
      staleBusyCount++;
      if (staleBusyCount >= STALE_BUSY_THRESHOLD) {
        const msgResult = await getClient(serverUrl).session.messages(...);
        if (!msgResult.error && Array.isArray(msgResult.data) && msgResult.data.length > 0) {
          const lastMsg = msgResult.data[msgResult.data.length - 1] as { info: { role?: string } };
          if ((lastMsg.info as { role?: string }).role === 'assistant') {
            // treat as idle — status map hasn't caught up
            break;
          }
        }
        staleBusyCount = 0;
      }
    }

(We have since hardened workaround #2 to also require info.time.completed to be set on the last assistant message, because the message row exists — with role: "assistant" and zero parts — from the moment the prompt is accepted, so the role check alone misfired on any genuinely long-running task.)

Neither workaround is a real fix — they're both heuristics compensating for the status map's async catch-up delay, and the second one in particular risks masking a genuinely stuck session (see #27907 for a case where a session is legitimately, permanently busy due to a stuck tool part, which a naive "last message is assistant" check would not catch, but a longer busy-streak on a session with no trailing assistant message would).

Related / Prior Art (not exact duplicates)

  • #32506opencode run --format json --session hangs after final answer is persisted. Same class of problem (completion signal lags actual completion), but manifests in the CLI's own event-stream wait (finish() waiting on session.status: idle from the stream) rather than in a raw GET /session/status poll from an external client. Closest existing report; worth linking.
  • PR #14222 — "fix(session): concurrent prompt race, leaked callbacks, stale busy status." Proposed fixing stale busy status by setting idle immediately after the run loop instead of after post-loop pruning. Closed unmerged; no explanation given.
  • #17657 / PR #32128 — desktop app's SolidJS session_status store uses a bare setStore instead of reconcile, so stale busy entries the server no longer reports never get cleared client-side. Same symptom family ("stuck busy indicator") but a different, purely client-side bug — not the same code path as the server's raw HTTP response.
  • #27907 — a session can become genuinely, permanently busy (not just laggy) when a question tool part gets stuck in state.status: "running" and never resolves, locking out all assertNotBusy-guarded APIs including deleteMessage. Different root cause (a stuck tool, not status-map lag), but relevant background for anyone reasoning about the "indefinite busy" tail of this issue — a pure "N consecutive busy polls → assume idle" heuristic (as in workaround Roadmap & Existing Issues #2 above) is not safe to blindly copy, since it could mask a case like Bug: deleteMessage (and other assertNotBusy-guarded APIs) permanently fail when a question tool part is stuck in state.status=running #27907 where the session actually needs recovery, not to be treated as done.

None of the above appear to be a duplicate of the specific behavior described here (server-side GET /session/status returning busy for a window after the prompt response has already closed, observed by a plain SDK client with no TUI/desktop store involved) — filing as a new issue, with the above linked for context and to avoid overlap.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions