Skip to content

Stream retry after partial output can duplicate a turn and re-run tool calls #88

Description

@iceteaSA

Found during a source audit of 9bf8f4c. Verified by reading the code; not yet observed in the wild. A PR is incoming.

What happens

packages/opencode/src/ws.ts tracks whether any output has already reached the consumer in a local emitted flag, set right after controller.enqueue(...) (line 506).

The rate-limit interception path checks that flag before deciding whether a failure is retryable, and deliberately refuses to retry once output has gone out:

// side-effecting tools, and double-bill — so end the turn WITHOUT a
// retry. The mark still steers the next turn off this account.
closeCompleted()

The two transport-failure handlers never consult it:

function onError(error: Event) {
  invalidate(new ResponseStreamError(errorMessage(error), { cause: error }))
}

function onClose(event: CloseEvent) {
  if (completed) return
  invalidate(new ResponseStreamError(closeMessage(...)))
}

invalidate() (lines 304-310) calls controller.error(error), and ResponseStreamError is the marker that says the turn may be re-sent. resetIdleTimeout (line 312+) reaches invalidate the same way.

So a socket error, an early close, or an idle timeout after partial output has already been delivered can still surface as retryable.

Impact

The turn can be replayed after the user has already received part of it: duplicated text, side-effecting tool calls run a second time, and the turn billed twice.

Trigger

Any output event, followed by a socket error / close / idle timeout before response.completed.

Note for whoever fixes it

Routing these paths into closeCompleted() is not the fix. That enqueues data: [DONE] and closes normally, so a turn that died halfway would look complete — trading duplication for silent truncation, which is harder to notice.

A pre-output failure should stay retryable. A post-output failure needs to fail visibly and non-retryably.

Affected

packages/opencode/src/ws.ts at 9bf8f4c, lines 304-310, 497-506, 540-555.

Metadata

Metadata

Assignees

No one assigned

    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