Repository navigation
[BUG]: Status.get() returns idle for a session absent from the status map #43093
Description
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
- Start a session and let it go idle, or start one and kill the process mid-turn.
- 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).
- 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
iceteaSA commentedon Aug 22, 2026
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::47is the only insertion site and is unreachable fortype: "idle". So the map's value domain is exactlybusy | retry—{type:"idle"}is structurally unconstructible as a map entry, andGET /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:
get(){type:"idle"}{type:"idle"}{type:"idle"}list()get()'s fail-open is what this issue reports. But a consumer that readslist()while holdingget()'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. Againstlist()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()andlist()— 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:
list()synthesise idle entries for known sessions, or drop the?? {type:"idle"}default inget()and return an explicit absent/unknown. The second is more honest but is a breaking change for existingget()callers.list(), and thatget()'s idle is indistinguishable from unknown.Worth noting for anyone building on this:
session.idle(published atstatus.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.completedis durable where the status map is not.neriousy commentedon Oct 19, 2026
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@latestorcurl -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.