Skip to content

[BUG]: Status.get() returns idle for a session absent from the status map #43093

Closed as not planned

Description

@iceteaSA

Describe the bug

packages/opencode/src/session/status.ts — set(sessionID, status) publishes Event.Status on transition, and on type === "idle" publishes Event.Idle and deletes the map entry. get() then returns { type: "idle" } for any session with no entry, so "absent" and "idle" are indistinguishable to every consumer.

That makes idle a positive claim about a session that may not exist. The status map is InstanceState.make(… new Map()) — per-process and non-durable — so after a host restart every session reads idle, including sessions that were in-flight when the process died. Any consumer gating completion, cleanup, or notification on idle sees a fleet-wide false quiesce at exactly the moment the truth is unknown. The failure is silent and reads as health.

Worth documenting alongside: idle fires on all termination shapes — clean, error, and interrupt — so it is a quiesce barrier, not a success signal. The absent/idle conflation invites reading it as success.

Expected behavior

Absent and idle should be distinguishable — get() returning undefined (or an explicit unknown) for a missing entry, with idle reserved for an observed transition. Consumers can then treat unknown as "must check durably" rather than "finished".

The durable side already carries the discriminator: message.data.time.completed, set at packages/opencode/src/session/processor.ts:595, is persisted and survives restart — present on 49,961 of 50,000 sampled assistant messages on a live database. A session whose latest assistant message lacks it was in-flight when the process died. The information exists; the status surface collapses it.

For contrast within the same area, the field validation in packages/opencode/src/plugin/shared.ts throws precisely on every malformed input rather than returning a default that reads as success.

Steps to reproduce

  1. Start a session and let it go idle, or start one and kill the process mid-turn.
  2. Call the status surface for a session ID that has no map entry (any session after a restart, or any session that has already gone idle once).
  3. Observe { type: "idle" } returned for a session the process has no state for.

Note: these steps are derived from the mechanism rather than from a recorded run — the finding came from reading the code path.

Environment

  • Version: reproduces on current dev
  • Platform: Linux (not platform-specific — the mechanism is in-memory state plus a defaulted read)

Additional context

Found while integrating an external supervisor process that consumes opencode session status to decide when a background task has finished. It surfaced there as a false-completion vector: the supervisor's candidate check reads idle, and on a host restart every session presents as idle at once.

A consumer can defend against this by treating "absent from the status map" as a distinct unknown state rather than as idle, but only if it already assumes the surface is lossy. A consumer taking get() at face value has no way to tell the two apart.

Activity

  1. iceteaSA commented on Aug 22, 2026

    @iceteaSA
    Author

    Additional evidence from a plugin that consumes this API in production, plus a second face of the same bug that makes it worse than it first looks.

    The two status APIs disagree about what absence means

    packages/opencode/src/session/status.ts:

    :30  const get = ... (sessionID) {
    :32    return data.get(sessionID) ?? { type: "idle" as const }   // absence → SYNTHESISED idle
    :33  }
    
    :35  const list = ... () {
    :36    return new Map(yield* InstanceState.get(state))            // absence → stays absent
    :37  }
    
    :39  const set = ... (sessionID, status) {
    :42    if (status.type === "idle") {
    :43      yield* events.publish(Event.Idle, { sessionID })
    :44      data.delete(sessionID)      // idle DELETES the entry
    :45      return
    :46    }
    :47    data.set(sessionID, status)   // only reachable when type !== "idle"
    :48  }

    :47 is the only insertion site and is unreachable for type: "idle". So the map's value domain is exactly busy | retry — {type:"idle"} is structurally unconstructible as a map entry, and GET /session/status (handlers/session.ts:85, Object.fromEntries(statusSvc.list())) can never report an idle session.

    That means the same underlying state has two opposite encodings:

    API idle session never-ran session host restarted
    get() {type:"idle"} {type:"idle"} {type:"idle"}
    list() absent absent absent

    get()'s fail-open is what this issue reports. But a consumer that reads list() while holding get()'s mental model gets the inverse failure — and both are reachable from the same public surface.

    Consumer-side cost, observed

    A plugin gating an automated action on "is this session idle?" implemented the conservative reading — absence means unknown, so don't act — which is the correct fail-safe posture for get()'s semantics. Against list() it silently ate 100% of the signal it was guarding: the gate could never open, because the only representation of idle is the absence being treated as unknown.

    Flight-recorder output shows the stall clearly: continuous {verdict: queue, clause: status-absent} re-evaluations at a 120s cadence against a session that was provably idle the whole time. The feature was structurally dead, not flaky. It took four diagnosis rounds before instrumentation named the cause, because every individual component behaved exactly as documented.

    Why this is worth fixing beyond the restart case

    The restart scenario in the original report is the sharpest symptom, but the root defect is that absence is doing double duty as both "idle" and "unknown", with opposite safety implications, and the two APIs resolve that ambiguity in opposite directions. Any consumer that switches between get() and list() — or reasons about one after reading the other — inherits a bug that no amount of correct local reasoning will catch.

    Suggestions, roughly in order of cost:

    1. Make the encodings agree. Either have list() synthesise idle entries for known sessions, or drop the ?? {type:"idle"} default in get() and return an explicit absent/unknown. The second is more honest but is a breaking change for existing get() callers.
    2. Document the asymmetry on both API surfaces if the behaviour is intentional — specifically that an idle session is represented by absence in list(), and that get()'s idle is indistinguishable from unknown.
    3. Distinguish "not running" from "not known" at the type level, which would let consumers make the safety call explicitly instead of inferring it from a hole in a map.

    Worth noting for anyone building on this: session.idle (published at status.ts:43, before the delete) is a positive edge rather than an inferred absence, so it is the more robust signal for event-driven consumers — with the caveat that it fires on all termination shapes (clean, error, interrupt) and so is a quiesce signal, never a success signal. For anything that must survive a restart, message.data.time.completed is durable where the status map is not.

  2. neriousy commented on Oct 19, 2026

    @neriousy
    Member

    The v1 status getter that reported idle for sessions it had no record of no longer exists in v2. The v2 API only lists sessions that are currently running, each finished run adds an idle entry to the session transcript recording whether it succeeded, failed or was interrupted, and runs cut off by a crash or restart are resumed when the server starts again. To get v2: npm i -g @opencode/cli@latest or curl -fsSL https://opencode.ai/v2/install | bash (desktop: update the app). If you still see this on v2, please open a new issue with the details.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions