Skip to content

fix(stream): recover from transient model-busy (10605) rejections - #41

Merged
simonsmh merged 1 commit into
simonsmh:mainfrom
summer-tt:fix/model-busy-recovery
Oct 22, 2026
Merged

simonsmh merged 1 commit into
simonsmh:mainfrom
summer-tt:fix/model-busy-recovery

Conversation

@summer-tt

Copy link
Copy Markdown
Contributor

Summary

Recover from transient Qoder model-busy rejections (10605 / RESOURCE_LIMIT) instead of failing the entire assistant turn immediately.

Qoder can return HTTP 200 with an SSE envelope whose statusCodeValue is 403. Its body contains nested JSON strings describing error 10605 and the scheduler state. Previously, the provider threw a generic Upstream status 403 error; that message was not classified as retryable by pi-ai.

Changes

  • Parse the nested model-busy envelope and recover only when isQueued === false and serviceAvailable === true. Real queues, missing readiness flags, and unrelated errors retain their previous behavior.
  • Retry before any model output only, honoring retryAfterSeconds. Default to two retries, honor an explicit maxRetries (including zero), and cap local retries at 10. Bound each wait by 5 seconds and any smaller positive maxRetryDelayMs; maxRetryDelayMs: 0 keeps the provider's 5-second cap.
  • Preserve request_set_id and session_id, regenerate request_id and chat_record_id together, and re-encode/re-sign each retry.
  • Clear per-attempt response metadata, token usage, Qoder credits, and finish reason before sending the next request. Cancel the rejected response and release its reader lock before retrying, and honor cancellation during waits and between attempts.
  • Emit one logical start event and one terminal event. Once text, thinking (including buffered tag fragments), or tool-call deltas have arrived, fail without retrying to avoid duplicating output.
  • When the local retry budget is exhausted or the requested wait exceeds the local cap, report a retryable model is busy error so the host's existing retry policy can take over.

This is bounded recovery for a ready scheduler response, not queue/status polling. It does not change the isQueued: true path.

Tests

Regression coverage includes nested-envelope parsing, readiness checks, retry limits and delay caps, request identity and signatures, state cleanup, partial-output protection, abort behavior, reader cleanup, and integration with pi-ai's retryAssistantCall.

  • npm run lint
  • npm run check
  • npm test — 22 test files, 399 tests passed
  • npm run build
  • git diff --cached --check

All regression fixtures are synthetic; no live Qoder requests are required.

Scope

Based on the latest upstream main, preserving its thinking-off fixes. Only src/protocol/stream.ts and the new src/__tests__/model-busy-retry.test.ts are changed; no generated dist files or unrelated fork commits are included. Most of the stream diff is indentation from moving one request attempt into a retryable function; git diff -w makes the behavioral changes easier to review.

Recognize Qoder's nested 10605 scheduler response and retry only ready rejections before any model output. Bound retries and delays, honor aborts, refresh request IDs and signatures, and clear all per-attempt metadata and usage.

Surface a retryable error when local recovery is exhausted, while rejecting retries after partial text, thinking, or tool output. Add synthetic regression coverage including pi-ai retry integration.
@simonsmh
simonsmh merged commit 4e13648 into simonsmh:main Oct 22, 2026
1 check passed
@simonsmh

Copy link
Copy Markdown
Owner

Merged (squash: 4e13648) and shipped in v0.6.3 — thanks @summer-tt! Reviewed locally (399 tests, lint/typecheck/build green) and smoke-tested against the real gateway for regressions in the normal path; the busy path can't be triggered on demand, but the fixtures mirror the real three-layer envelope closely and the partial-output / abort / harness-handoff cases are all covered. Appreciate the careful scoping — only the ready state (isQueued: false + serviceAvailable: true) recovers, everything else keeps the previous behavior.

jischeng added a commit to jischeng/pi-provider-qoder that referenced this pull request Oct 23, 2026
Sync upstream v0.6.2..v0.6.5 while keeping this fork's features, then fix the
bugs that surfaced during the review. The upstream changes and the fixes touch
the same functions, so they land together rather than as artificially split
commits that would not pass on their own.

Upstream taken: tool-call aggregation by ID instead of stream index (simonsmh#39),
combined thinking-disable signals (simonsmh#40) with the effort-less catalog exception,
bounded model-busy recovery (simonsmh#41), omp effort metadata (simonsmh#42), service-enabled
Auto retention (simonsmh#43), COSY version tracking, and wire-format tool-call IDs.
Fork behaviour preserved: multi-account providers, per-account entitlement
pre-flight, price factors, keep-alive SSE, premature-disconnect surfacing,
XML <tool_call> recovery, and always-on-thinking effort fallback.

Fixes:

1. Cross-region credential leak. The global provider prefix ("qoder") is a
   strict prefix of the CN one ("qoder-cn"), so `id.startsWith(prefix + "-")`
   matched CN slots when listing global accounts. A global catalogue refresh
   then called /model/list with CN credentials against the GLOBAL endpoint and
   wrote that account into the global cache. Added isProviderOfMode() in
   region.ts and routed both account discovery and re-registration through it.

2. System-message folding was computed and discarded. The request body still
   prepended systemText to the unfiltered list, so it carried the top-level
   prompt plus every system turn instead of one merged message.

3. Orphan `tool` messages. A result whose id no preceding assistant turn
   declared was passed through (the "no assistant to pair with" case returned
   null, which read as permission rather than a veto). Upstreams reject that
   with "tool must follow a message with tool_calls", failing every later
   request in the session. Affected: resumed mid-turn, a user turn between call
   and result, and calls the lookahead trimmed.

4. Startup could hang forever. The default export awaits automatic login, and
   the PAT exchange / token refresh had no deadline, so an upstream that
   accepted the connection and never answered blocked extension load with no
   models, no UI, and no error. Bounded via authFetchSignal() (15s, merged with
   the caller's signal); the usage fetch defaults to a 10s timeout too.

5. Multi-pane torn reads of the COSY version cache. Plain writeFileSync let a
   second process parse a half-written body, throwing and leaving that pane on
   the bundled fallback version for the rest of the day. Now write-then-rename,
   matching the model catalogue.

6. Non-object tool arguments. `<tool_call>` XML whose arguments were an array,
   scalar, null, or a non-JSON string produced a value violating
   `ToolCall.arguments: JsonObject`, so argument validation failed instead of
   the tool running with no arguments.

7. Content order shuffled around tool calls. When a stream produced text, a
   tool call, then more text, the trailing text was appended to the block
   BEFORE the call: content read ["BEFOREAFTER", toolCall] and events addressed
   indexes that no longer matched. Root cause was a cached text-block index in
   ThinkingTagParser.emitText (and the equivalent branch in stream.ts) that was
   never re-checked once tool calls appended to `content`. Present upstream in
   v0.6.5 as well; its test asserted the shuffled order, so that assertion is
   corrected and a contentIndex-consistency check added.

Also fixes provider re-registration across session replacement (issue #1): the
registered-provider set was module-level, and ESM modules stay cached, so
/resume, /new and /fork built a ModelRuntime with zero Qoder providers and the
picker fell back to the default model. Now a per-instance WeakMap cleared on
session_shutdown.

Deviating points from upstream are marked `// fork:` in the source so the next
sync can enumerate them mechanically.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants