mixdog 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/gate-local.mjs +19 -3
- package/scripts/model-catalog-audit.mjs +1 -15
- package/src/agents/reviewer/AGENT.md +3 -3
- package/src/agents/reviewer/agent.json +1 -1
- package/src/defaults/shipped-history.json +1 -0
- package/src/repl/slash-commands.mjs +16 -1
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
- package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
- package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
- package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
- package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
- package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
- package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
- package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
- package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
- package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
- package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
- package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
- package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
- package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
- package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
- package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
- package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
- package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
- package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
- package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
- package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
- package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
- package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
- package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
- package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
- package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
- package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
- package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
- package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/memory/lib/pg/process.mjs +34 -24
- package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
- package/src/runtime/office/README.md +1 -1
- package/src/runtime/office/shared/values.mjs +0 -5
- package/src/runtime/shared/background-tasks.mjs +92 -14
- package/src/runtime/shared/err-text.mjs +25 -0
- package/src/runtime/shared/error-presentation.mjs +159 -19
- package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
- package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
- package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
- package/src/runtime/shared/llm/model-catalog.mjs +53 -40
- package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
- package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
- package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
- package/src/runtime/shared/model-display.mjs +13 -0
- package/src/runtime/shared/quota-resume-store.mjs +11 -0
- package/src/runtime/shared/session-baseline-store.mjs +960 -0
- package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
- package/src/runtime/shared/task-notification-envelope.mjs +2 -2
- package/src/runtime/shared/turn-changes.mjs +447 -219
- package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
- package/src/runtime/shared/turn-snapshot.mjs +170 -48
- package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
- package/src/runtime/shared/worktrees/contract.mjs +0 -24
- package/src/runtime/shared/worktrees/test-support.mjs +19 -4
- package/src/session-runtime/boot/apis.mjs +6 -0
- package/src/session-runtime/boot/infrastructure.mjs +6 -0
- package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
- package/src/session-runtime/lifecycle/teardown.mjs +1 -1
- package/src/session-runtime/mcp-glue/connect.mjs +29 -9
- package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
- package/src/session-runtime/mcp-glue.mjs +3 -0
- package/src/session-runtime/mcp-oauth-login.mjs +174 -0
- package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
- package/src/session-runtime/model-route/set-route.mjs +83 -15
- package/src/session-runtime/model-route/web-search-route.mjs +1 -1
- package/src/session-runtime/native-web-search/routes.mjs +22 -3
- package/src/session-runtime/provider-models/model-meta.mjs +12 -3
- package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
- package/src/session-runtime/quick-web-search-models.mjs +13 -38
- package/src/session-runtime/resource-extension-sync.mjs +4 -0
- package/src/session-runtime/resource-mcp-api.mjs +98 -2
- package/src/session-runtime/runtime-review-api.mjs +13 -0
- package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
- package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
- package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
- package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
- package/src/session-runtime/settings-compaction-api.mjs +1 -0
- package/src/standalone/daemon-drain-policy.mjs +8 -0
- package/src/standalone/daemon-telemetry.mjs +55 -0
- package/src/standalone/daemon.mjs +3 -7
- package/src/standalone/session-protocol.mjs +7 -0
- package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
- package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
- package/src/standalone/session-service/agent-tree.mjs +12 -0
- package/src/standalone/session-service/quota-resume.mjs +8 -0
- package/src/standalone/session-service/retention/busy.mjs +15 -1
- package/src/standalone/session-service-api.mjs +5 -0
- package/src/standalone/session-service.mjs +1 -0
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/create-app-pickers.mjs +4 -0
- package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
- package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
- package/src/tui/app/extension-pickers.mjs +2 -1
- package/src/tui/app/model-options.mjs +11 -16
- package/src/tui/app/slash-commands.mjs +7 -2
- package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
- package/src/tui/components/StatusLine.jsx +2 -0
- package/src/tui/components/status-line/local-line.mjs +5 -2
- package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
- package/src/tui/dist/index.mjs +816 -43
- package/src/tui/session/quota-resume.mjs +46 -14
- package/src/tui/session/session-api/commands.mjs +33 -24
- package/src/tui/session/session-api/extensions.mjs +22 -0
- package/src/tui/session/session-api/integrations.mjs +7 -0
- package/src/tui/session/session-api/lifecycle.mjs +5 -1
- package/src/tui/session/session-api/routes.mjs +71 -7
- package/src/tui/session/session-api/settings.mjs +5 -14
- package/src/tui/session/session-flow/drain.mjs +7 -1
- package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
- package/src/tui/session/turn.mjs +38 -15
- package/src/tui/statusline-ansi-bridge.mjs +1 -1
- package/src/ui/statusline-agents.mjs +15 -4
- package/src/ui/statusline.mjs +7 -3
- package/src/ui/streaming-markdown-heal.mjs +34 -3
- package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
- package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
- package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { mergeFlatSchemaProperty } from './lib/flat-schema-merge.mjs';
|
|
2
|
+
import { REAUTH_GUIDANCE } from './cursor-auth.mjs';
|
|
3
|
+
import { isTransientErrorCode } from './provider-error-kind.mjs';
|
|
2
4
|
|
|
3
5
|
const SCHEMA_ANNOTATIONS = new Set([
|
|
4
6
|
'$comment',
|
|
@@ -32,6 +34,15 @@ export function resolveCursorStreamTuning(env = process.env) {
|
|
|
32
34
|
idleTimeoutMs: boundedInteger(env.MIXDOG_CURSOR_STREAM_IDLE_TIMEOUT_MS, 180_000),
|
|
33
35
|
parkTimeoutMs: boundedInteger(env.MIXDOG_CURSOR_STREAM_PARK_TIMEOUT_MS, 45_000, { min: 1_000 }),
|
|
34
36
|
maxRetries: boundedInteger(env.MIXDOG_CURSOR_STREAM_IDLE_MAX_RETRIES, 5, { max: 10 }),
|
|
37
|
+
// Transport retry: 250ms base, 2s ceiling.
|
|
38
|
+
retryBaseDelayMs: boundedInteger(env.MIXDOG_CURSOR_STREAM_RETRY_BASE_MS, 250, { max: 60_000 }),
|
|
39
|
+
retryMaxDelayMs: boundedInteger(env.MIXDOG_CURSOR_STREAM_RETRY_MAX_MS, 2_000, { max: 120_000 }),
|
|
40
|
+
// A rate limit gets one recovery (min(1, maxRetries)).
|
|
41
|
+
maxThrottleRetries: boundedInteger(env.MIXDOG_CURSOR_THROTTLE_MAX_RETRIES, 1, { max: 5 }),
|
|
42
|
+
// One credential-refresh retry; the outer provider layer adds none for a
|
|
43
|
+
// stream that already ran, and re-sending a rejected credential only
|
|
44
|
+
// hammers the token endpoint.
|
|
45
|
+
maxAuthRetries: boundedInteger(env.MIXDOG_CURSOR_AUTH_MAX_RETRIES, 1, { max: 5 }),
|
|
35
46
|
};
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -221,6 +232,13 @@ export function createCursorStreamWatchdog({ idleTimeoutMs, parkTimeoutMs, onTim
|
|
|
221
232
|
if (kind === 'work') arm(idleTimeoutMs, 'idle');
|
|
222
233
|
else if (kind === 'park') arm(parkTimeoutMs, 'park');
|
|
223
234
|
},
|
|
235
|
+
// Suspend both deadlines during an intentional retry wait; the next
|
|
236
|
+
// start()/progress('work'|'park') re-arms them, so only real stream
|
|
237
|
+
// silence is ever counted.
|
|
238
|
+
pause() {
|
|
239
|
+
if (timer) clearTimeout(timer);
|
|
240
|
+
timer = null;
|
|
241
|
+
},
|
|
224
242
|
stop() {
|
|
225
243
|
stopped = true;
|
|
226
244
|
if (timer) clearTimeout(timer);
|
|
@@ -251,8 +269,85 @@ export function cursorInteractionProgress(update) {
|
|
|
251
269
|
return 'none';
|
|
252
270
|
}
|
|
253
271
|
|
|
272
|
+
// Codes that describe a transient server verdict or a recognized transport
|
|
273
|
+
// truncation. Everything else (protocol/decoding errors, 4xx verdicts,
|
|
274
|
+
// unknown failures) is deterministic and is never replayed in place.
|
|
275
|
+
const TRANSIENT_STREAM_CODES = new Set([
|
|
276
|
+
'unavailable',
|
|
277
|
+
'internal',
|
|
278
|
+
'deadline_exceeded',
|
|
279
|
+
'stream_aborted',
|
|
280
|
+
'goaway',
|
|
281
|
+
'connection_timeout',
|
|
282
|
+
'stream_idle_timeout',
|
|
283
|
+
'stream_park_timeout',
|
|
284
|
+
'incomplete_stream',
|
|
285
|
+
'ERR_HTTP2_STREAM_CANCEL',
|
|
286
|
+
'ERR_HTTP2_GOAWAY_SESSION',
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
// Protocol-mismatch check: Cursor wraps a wire mismatch in `internal`, and
|
|
290
|
+
// re-sending a request the schema cannot decode only burns the retry budget.
|
|
291
|
+
// Tested on the server's own detail, before the transient code is trusted.
|
|
292
|
+
const PROTOCOL_DRIFT_RE = /\b(protocol|protobuf|wire drift|invalid argument|failed_precondition)\b/i;
|
|
293
|
+
|
|
294
|
+
export function isCursorProtocolDrift(error) {
|
|
295
|
+
if (error?.code === 'protocol_error') return true;
|
|
296
|
+
return PROTOCOL_DRIFT_RE.test(String(error?.cursorDetail ?? ''));
|
|
297
|
+
}
|
|
298
|
+
|
|
254
299
|
export function isRetryableCursorStreamError(error) {
|
|
255
|
-
const status = Number(error?.httpStatus || error?.status || 0);
|
|
256
300
|
if (error?.name === 'AbortError') return false;
|
|
257
|
-
|
|
301
|
+
const code = String(error?.code || '');
|
|
302
|
+
if (!TRANSIENT_STREAM_CODES.has(code) && !isTransientErrorCode(code)) return false;
|
|
303
|
+
return !isCursorProtocolDrift(error);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Rejected credentials: worth one forced token refresh before giving up.
|
|
307
|
+
export function isCursorAuthError(error) {
|
|
308
|
+
if (error?.name === 'AbortError') return false;
|
|
309
|
+
return error?.code === 'unauthenticated' || error?.code === 'permission_denied';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Credentials for in-place restarts. Every retry force-refreshes and keeps the
|
|
313
|
+
// previous token if the refresh fails; a rejected credential is
|
|
314
|
+
// only retried when the refresh produced a different token.
|
|
315
|
+
export function createCursorCredentialRefresher(initialToken, getAccessToken) {
|
|
316
|
+
let current = initialToken;
|
|
317
|
+
return {
|
|
318
|
+
async refresh(reason, error) {
|
|
319
|
+
let next = current;
|
|
320
|
+
if (getAccessToken) {
|
|
321
|
+
try {
|
|
322
|
+
next = (await getAccessToken({ forceRefresh: true })) || current;
|
|
323
|
+
} catch {
|
|
324
|
+
/* keep the previous token */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (reason === 'auth' && next === current) throw error;
|
|
328
|
+
current = next;
|
|
329
|
+
return current;
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Final-failure guidance appended once to the original Cursor detail
|
|
335
|
+
// (transport-failure hint).
|
|
336
|
+
export function withCursorFailureHint(error) {
|
|
337
|
+
if (!(error instanceof Error) || error.cursorHintApplied) return error;
|
|
338
|
+
const hint = isCursorAuthError(error)
|
|
339
|
+
? REAUTH_GUIDANCE
|
|
340
|
+
: isCursorProtocolDrift(error)
|
|
341
|
+
? 'Cursor may have changed its wire protocol; update Mixdog or set MIXDOG_CURSOR_CLIENT_VERSION if this persists.'
|
|
342
|
+
: '';
|
|
343
|
+
if (!hint) return error;
|
|
344
|
+
error.cursorHintApplied = true;
|
|
345
|
+
error.message = `${error.message} ${hint}`;
|
|
346
|
+
return error;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// resource_exhausted / 429 that is a throttle, not an oversized request.
|
|
350
|
+
export function isCursorThrottleError(error) {
|
|
351
|
+
if (error?.name === 'AbortError' || error?.code === 'request_too_large') return false;
|
|
352
|
+
return error?.code === 'resource_exhausted' || Number(error?.httpStatus || error?.status || 0) === 429;
|
|
258
353
|
}
|
|
@@ -3,11 +3,30 @@
|
|
|
3
3
|
* response: frame parsing, tool-batch hand-off to the run registry, the
|
|
4
4
|
* close verdict and the bounded in-place restart.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
isCursorAuthError,
|
|
8
|
+
isCursorThrottleError,
|
|
9
|
+
isRetryableCursorStreamError,
|
|
10
|
+
withCursorFailureHint,
|
|
11
|
+
} from './cursor-wire-guards.mjs';
|
|
12
|
+
import { emitProviderRetryStage, markProviderRecoveryExhausted, retryAfterMsFromError } from './retry-classifier.mjs';
|
|
8
13
|
import { createFrameParser, cursorError, parseEndStream } from './cursor-wire-transport.mjs';
|
|
9
14
|
import { processServerMessage } from './cursor-wire-stream-messages.mjs';
|
|
10
15
|
|
|
16
|
+
// Resolves after `ms`, or early when the stream is cancelled.
|
|
17
|
+
function abortableSleep(ms, signal) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
if (signal?.aborted) return resolve();
|
|
20
|
+
const done = () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
signal?.removeEventListener('abort', done);
|
|
23
|
+
resolve();
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(done, ms);
|
|
26
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
/**
|
|
12
31
|
* @param {object} ctx
|
|
13
32
|
* @param {{ bridge, heartbeat }} ctx.live the bridge currently feeding the stream (mutated on restart)
|
|
@@ -16,7 +35,10 @@ import { processServerMessage } from './cursor-wire-stream-messages.mjs';
|
|
|
16
35
|
*/
|
|
17
36
|
export function createBridgeController(ctx) {
|
|
18
37
|
const { live, state, filter, watchdog, sink, tuning, run, runs, restart, isCancelled } = ctx;
|
|
38
|
+
const { sleep = abortableSleep, cancelSignal = null, onStageChange = null } = ctx;
|
|
19
39
|
let retryCount = 0;
|
|
40
|
+
let throttleCount = 0;
|
|
41
|
+
let authCount = 0;
|
|
20
42
|
|
|
21
43
|
const finishToolBatch = () => {
|
|
22
44
|
if (state.closed || state.pending.length === 0) return;
|
|
@@ -35,37 +57,91 @@ export function createBridgeController(ctx) {
|
|
|
35
57
|
sink.finish('tool_calls');
|
|
36
58
|
};
|
|
37
59
|
|
|
60
|
+
// Exponential backoff with +-20% jitter for in-place retries.
|
|
61
|
+
const backoffMs = (attempt) => {
|
|
62
|
+
const ceiling = Math.min(tuning.retryMaxDelayMs, tuning.retryBaseDelayMs * 2 ** (attempt - 1));
|
|
63
|
+
return Math.floor(ceiling * (0.8 + Math.random() * 0.4));
|
|
64
|
+
};
|
|
65
|
+
|
|
38
66
|
const recoverOrFail = (error) => {
|
|
39
67
|
const retryable = isRetryableCursorStreamError(error);
|
|
68
|
+
// A throttle retries within the bounded throttle retry count; otherwise
|
|
69
|
+
// the error surfaces with retryAfterMs for the outer layer.
|
|
70
|
+
const throttled = !retryable && isCursorThrottleError(error);
|
|
71
|
+
// Rejected credentials get a forced token refresh before the retry.
|
|
72
|
+
const authFailure = !retryable && !throttled && isCursorAuthError(error);
|
|
73
|
+
// A throttle retry rotates the conversation and drops the checkpoint
|
|
74
|
+
// so unlike other recoveries it can only replay a run that
|
|
75
|
+
// has shown nothing; the rest may also continue from a checkpoint.
|
|
40
76
|
const recoveryEligible =
|
|
41
|
-
!isCancelled() &&
|
|
42
|
-
|
|
77
|
+
!isCancelled() &&
|
|
78
|
+
typeof restart === 'function' &&
|
|
79
|
+
(!state.visibleOutput || (!throttled && Boolean(run.conversation.checkpoint)));
|
|
80
|
+
const serverDelay = retryAfterMsFromError(error);
|
|
81
|
+
// Server advice is honored in full for every retryable kind; local
|
|
82
|
+
// backoff applies only when the server gave none.
|
|
83
|
+
const delayMs = Number.isFinite(serverDelay)
|
|
84
|
+
? serverDelay
|
|
85
|
+
: backoffMs(throttled ? throttleCount + 1 : retryCount + 1);
|
|
86
|
+
const throttleOk = throttled && throttleCount < tuning.maxThrottleRetries;
|
|
87
|
+
const authOk = authFailure && authCount < tuning.maxAuthRetries;
|
|
88
|
+
const canRetry =
|
|
89
|
+
retryCount < tuning.maxRetries && recoveryEligible && (retryable || throttleOk || authOk);
|
|
43
90
|
if (!canRetry) {
|
|
44
91
|
// A fresh Cursor run owns its bounded in-place retries. Mark
|
|
45
92
|
// exhaustion so the outer loop does not multiply that budget.
|
|
46
93
|
// Resumed tool-result streams have no restart closure; those
|
|
47
94
|
// intentionally fall through unmarked so the outer loop can
|
|
48
95
|
// rebuild from the committed assistant/tool-result history.
|
|
49
|
-
|
|
96
|
+
// A throttle that spent its bounded retries is final for the turn too.
|
|
97
|
+
if (
|
|
98
|
+
recoveryEligible &&
|
|
99
|
+
((retryable && retryCount >= tuning.maxRetries) ||
|
|
100
|
+
(throttled && throttleCount >= tuning.maxThrottleRetries))
|
|
101
|
+
) {
|
|
50
102
|
markProviderRecoveryExhausted(error, { owner: 'cursor-wire', attempts: retryCount + 1 });
|
|
51
103
|
}
|
|
52
|
-
sink.fail(error);
|
|
104
|
+
sink.fail(withCursorFailureHint(error));
|
|
53
105
|
return;
|
|
54
106
|
}
|
|
55
107
|
retryCount += 1;
|
|
108
|
+
if (throttled) throttleCount += 1;
|
|
109
|
+
if (authFailure) authCount += 1;
|
|
56
110
|
state.sawEnd = false;
|
|
57
111
|
state.batchBoundaryReady = false;
|
|
58
112
|
state.batchBoundaryChunkSeq = -1;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
113
|
+
// The wait is intentional, not stream silence; attach() re-arms on restart.
|
|
114
|
+
watchdog.pause();
|
|
115
|
+
emitProviderRetryStage(onStageChange, {
|
|
116
|
+
attempt: retryCount + 1,
|
|
117
|
+
maxAttempts: tuning.maxRetries + 1,
|
|
118
|
+
lastErr: error,
|
|
119
|
+
delayMs,
|
|
120
|
+
});
|
|
121
|
+
void sleep(delayMs, cancelSignal).then(async () => {
|
|
122
|
+
if (isCancelled() || state.closed) return;
|
|
123
|
+
try {
|
|
124
|
+
// restart() may refresh credentials first, so it can be async.
|
|
125
|
+
const next = await restart({
|
|
126
|
+
attempt: retryCount,
|
|
127
|
+
fromCheckpoint: !throttled && Boolean(run.conversation.checkpoint),
|
|
128
|
+
visibleOutput: state.visibleOutput,
|
|
129
|
+
reason: throttled ? 'throttle' : authFailure ? 'auth' : 'transient',
|
|
130
|
+
error,
|
|
131
|
+
});
|
|
132
|
+
if (isCancelled() || state.closed) {
|
|
133
|
+
clearInterval(next.heartbeat);
|
|
134
|
+
next.bridge.close();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
attach(next.bridge, next.heartbeat);
|
|
138
|
+
try {
|
|
139
|
+
onStageChange?.('streaming');
|
|
140
|
+
} catch {}
|
|
141
|
+
} catch (restartError) {
|
|
142
|
+
sink.fail(withCursorFailureHint(restartError));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
69
145
|
};
|
|
70
146
|
|
|
71
147
|
/** The Connect end frame: an error closes, pending tools delimit, a finished turn completes. */
|
|
@@ -99,20 +175,23 @@ export function createBridgeController(ctx) {
|
|
|
99
175
|
closeError = frameError;
|
|
100
176
|
}
|
|
101
177
|
}
|
|
102
|
-
// Cursor commonly closes HTTP/2 immediately after turnEnded without
|
|
103
|
-
// a separate Connect end frame. The turn is already complete.
|
|
104
|
-
if (state.sawTurnEnded) {
|
|
105
|
-
sink.finish();
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
178
|
// Tool calls already emitted to the caller remain actionable even if
|
|
109
179
|
// the parked transport vanished. The next request rebuilds/resumes.
|
|
180
|
+
// Checked before turnEnded: a close that follows turnEnded only ends a
|
|
181
|
+
// completed turn when no exec is still waiting (turn ended
|
|
182
|
+
// with no MCP exec outstanding), never by dropping a tool call.
|
|
110
183
|
if (state.pending.length > 0) {
|
|
111
184
|
sink.finish('tool_calls');
|
|
112
185
|
return;
|
|
113
186
|
}
|
|
187
|
+
// Cursor commonly closes HTTP/2 immediately after turnEnded, with or
|
|
188
|
+
// without an end-stream error. The turn is already complete.
|
|
189
|
+
if (state.sawTurnEnded) {
|
|
190
|
+
sink.finish();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
114
193
|
if (!closeError && !state.sawEnd) {
|
|
115
|
-
closeError = cursorError('Cursor stream closed before its end frame', { code: '
|
|
194
|
+
closeError = cursorError('Cursor stream closed before its end frame', { code: 'incomplete_stream' });
|
|
116
195
|
}
|
|
117
196
|
if (closeError) recoverOrFail(closeError);
|
|
118
197
|
else sink.finish();
|
|
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import http2 from 'node:http2';
|
|
5
5
|
import { MAX_CONNECT_FRAME_BYTES, createCursorByteQueue } from './cursor-wire-guards.mjs';
|
|
6
6
|
import { cursorClientVersion, warmCursorClientVersion } from './cursor-client-version.mjs';
|
|
7
|
+
import { retryAfterMsFromError } from './retry-classifier.mjs';
|
|
7
8
|
|
|
8
9
|
export { warmCursorClientVersion };
|
|
9
10
|
|
|
@@ -40,7 +41,66 @@ function rpcHeaders(accessToken, path, unary) {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// HTTP-equivalent status for every Connect error code.
|
|
45
|
+
const CONNECT_CODE_STATUS = Object.freeze({
|
|
46
|
+
canceled: 499,
|
|
47
|
+
unknown: 500,
|
|
48
|
+
invalid_argument: 400,
|
|
49
|
+
deadline_exceeded: 504,
|
|
50
|
+
not_found: 404,
|
|
51
|
+
already_exists: 409,
|
|
52
|
+
permission_denied: 403,
|
|
53
|
+
resource_exhausted: 429,
|
|
54
|
+
failed_precondition: 400,
|
|
55
|
+
aborted: 409,
|
|
56
|
+
out_of_range: 400,
|
|
57
|
+
unimplemented: 501,
|
|
58
|
+
internal: 500,
|
|
59
|
+
unavailable: 503,
|
|
60
|
+
data_loss: 500,
|
|
61
|
+
unauthenticated: 401,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Connect code for a plain HTTP failure that carried no Connect error body.
|
|
65
|
+
const HTTP_STATUS_CODE = Object.freeze({
|
|
66
|
+
401: 'unauthenticated',
|
|
67
|
+
403: 'permission_denied',
|
|
68
|
+
404: 'not_found',
|
|
69
|
+
429: 'resource_exhausted',
|
|
70
|
+
500: 'internal',
|
|
71
|
+
502: 'unavailable',
|
|
72
|
+
503: 'unavailable',
|
|
73
|
+
504: 'deadline_exceeded',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Only explicit wording about the tool catalog or request payload being too
|
|
77
|
+
// large counts; resource_exhausted otherwise stays a throttle.
|
|
78
|
+
const REQUEST_TOO_LARGE_RE =
|
|
79
|
+
/\b(?:tool\s+(?:catalog|definitions?|schemas?)|tools|payload|request(?:\s+body)?)\b[^.\n]{0,80}?\b(?:too\s+(?:large|big)|exceed(?:s|ed)?\b[^.\n]{0,30}?\b(?:size|limit|maximum))/i;
|
|
80
|
+
|
|
81
|
+
// Rate/quota wording wins over the size match: "request exceeded the rate
|
|
82
|
+
// limit" is a throttle, not an oversized request.
|
|
83
|
+
const THROTTLE_CUE_RE = /too\s+many\s+requests|quota|rate[\s_-]*limit|throttl/i;
|
|
84
|
+
|
|
85
|
+
function firstValue(value) {
|
|
86
|
+
return Array.isArray(value) ? value[0] : value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lowercased string-valued header map without HTTP/2 pseudo-headers.
|
|
90
|
+
function plainHeaders(source) {
|
|
91
|
+
const headers = {};
|
|
92
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
93
|
+
const first = firstValue(value);
|
|
94
|
+
if (key.startsWith(':') || first == null || typeof first === 'object') continue;
|
|
95
|
+
headers[key.toLowerCase()] = String(first);
|
|
96
|
+
}
|
|
97
|
+
return headers;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function cursorError(
|
|
101
|
+
message,
|
|
102
|
+
{ status = 0, code = '', cursorCode = code, headers = null, retryAfter = null, reauthRequired = false, detail } = {}
|
|
103
|
+
) {
|
|
44
104
|
const error = new Error(message);
|
|
45
105
|
if (status) {
|
|
46
106
|
error.status = status;
|
|
@@ -48,17 +108,32 @@ export function cursorError(message, { status = 0, code = '', retryAfter = null
|
|
|
48
108
|
}
|
|
49
109
|
if (code) {
|
|
50
110
|
error.code = code;
|
|
51
|
-
error.cursorCode =
|
|
111
|
+
error.cursorCode = cursorCode;
|
|
52
112
|
}
|
|
113
|
+
if (detail != null) error.cursorDetail = detail;
|
|
114
|
+
if (reauthRequired) error.reauthRequired = true;
|
|
115
|
+
const errorHeaders = plainHeaders(headers);
|
|
53
116
|
if (retryAfter != null && retryAfter !== '') {
|
|
54
|
-
const headers = { 'retry-after': String(retryAfter) };
|
|
55
117
|
error.retryAfter = retryAfter;
|
|
56
|
-
|
|
57
|
-
|
|
118
|
+
errorHeaders['retry-after'] = String(retryAfter);
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(errorHeaders).length) {
|
|
121
|
+
error.headers = errorHeaders;
|
|
122
|
+
error.response = { status, headers: errorHeaders };
|
|
123
|
+
const retryAfterMs = retryAfterMsFromError(error);
|
|
124
|
+
if (Number.isFinite(retryAfterMs)) error.retryAfterMs = retryAfterMs;
|
|
58
125
|
}
|
|
59
126
|
return error;
|
|
60
127
|
}
|
|
61
128
|
|
|
129
|
+
function httpStatusError(status, headers) {
|
|
130
|
+
return cursorError(`Cursor request failed (${status})`, {
|
|
131
|
+
status,
|
|
132
|
+
code: HTTP_STATUS_CODE[status] || '',
|
|
133
|
+
headers,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
62
137
|
export function openCursorStream({ accessToken, path = RUN_PATH, url = API_URL }) {
|
|
63
138
|
const session = http2.connect(url);
|
|
64
139
|
const request = session.request(rpcHeaders(accessToken, path, false));
|
|
@@ -66,7 +141,7 @@ export function openCursorStream({ accessToken, path = RUN_PATH, url = API_URL }
|
|
|
66
141
|
let closeHandler = null;
|
|
67
142
|
let closed = false;
|
|
68
143
|
let status = 0;
|
|
69
|
-
let
|
|
144
|
+
let responseHeaders = null;
|
|
70
145
|
let closeError = null;
|
|
71
146
|
let timeout = setTimeout(
|
|
72
147
|
() => close(cursorError('Cursor connection timed out', { code: 'connection_timeout' })),
|
|
@@ -97,8 +172,7 @@ export function openCursorStream({ accessToken, path = RUN_PATH, url = API_URL }
|
|
|
97
172
|
closed = true;
|
|
98
173
|
clearTimeout(timeout);
|
|
99
174
|
clearInterval(ping);
|
|
100
|
-
closeError =
|
|
101
|
-
error || (status >= 400 ? cursorError(`Cursor request failed (${status})`, { status, retryAfter }) : null);
|
|
175
|
+
closeError = error || (status >= 400 ? httpStatusError(status, responseHeaders) : null);
|
|
102
176
|
try {
|
|
103
177
|
request.close();
|
|
104
178
|
} catch {}
|
|
@@ -120,7 +194,7 @@ export function openCursorStream({ accessToken, path = RUN_PATH, url = API_URL }
|
|
|
120
194
|
|
|
121
195
|
request.on('response', (headers) => {
|
|
122
196
|
status = Number(headers[':status'] || 0);
|
|
123
|
-
|
|
197
|
+
responseHeaders = headers;
|
|
124
198
|
resetTimeout();
|
|
125
199
|
});
|
|
126
200
|
request.on('data', (chunk) => {
|
|
@@ -167,7 +241,7 @@ export async function callCursorUnary({ accessToken, path, body, url = API_URL,
|
|
|
167
241
|
const request = session.request(rpcHeaders(accessToken, path, true));
|
|
168
242
|
const chunks = [];
|
|
169
243
|
let status = 0;
|
|
170
|
-
let
|
|
244
|
+
let responseHeaders = null;
|
|
171
245
|
let settled = false;
|
|
172
246
|
const timeout = setTimeout(() => finish(new Error('Cursor request timed out')), timeoutMs);
|
|
173
247
|
const finish = (error = null) => {
|
|
@@ -181,12 +255,12 @@ export async function callCursorUnary({ accessToken, path, body, url = API_URL,
|
|
|
181
255
|
session.close();
|
|
182
256
|
} catch {}
|
|
183
257
|
if (error) reject(error);
|
|
184
|
-
else if (status >= 400) reject(
|
|
258
|
+
else if (status >= 400) reject(httpStatusError(status, responseHeaders));
|
|
185
259
|
else resolve(Buffer.concat(chunks));
|
|
186
260
|
};
|
|
187
261
|
request.on('response', (headers) => {
|
|
188
262
|
status = Number(headers[':status'] || 0);
|
|
189
|
-
|
|
263
|
+
responseHeaders = headers;
|
|
190
264
|
});
|
|
191
265
|
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
192
266
|
request.on('end', () => finish());
|
|
@@ -235,7 +309,7 @@ export function createFrameParser(onMessage, onEnd) {
|
|
|
235
309
|
};
|
|
236
310
|
parse.finish = () => {
|
|
237
311
|
if (pending.byteLength) {
|
|
238
|
-
throw cursorError('Cursor stream ended with a truncated frame', { code: '
|
|
312
|
+
throw cursorError('Cursor stream ended with a truncated frame', { code: 'incomplete_stream' });
|
|
239
313
|
}
|
|
240
314
|
};
|
|
241
315
|
parse.bufferedBytes = () => pending.byteLength;
|
|
@@ -246,26 +320,22 @@ export function parseEndStream(bytes) {
|
|
|
246
320
|
try {
|
|
247
321
|
const payload = JSON.parse(textDecoder.decode(bytes));
|
|
248
322
|
if (!payload?.error) return null;
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
retryAfter:
|
|
264
|
-
|
|
265
|
-
payload.error.retry_after ??
|
|
266
|
-
payload.metadata?.['retry-after'] ??
|
|
267
|
-
payload.metadata?.retryAfter ??
|
|
268
|
-
null,
|
|
323
|
+
const connectCode = String(payload.error.code || 'error').toLowerCase();
|
|
324
|
+
const detail = String(payload.error.message || 'request failed');
|
|
325
|
+
const tooLarge = connectCode === 'resource_exhausted' && !THROTTLE_CUE_RE.test(detail) && REQUEST_TOO_LARGE_RE.test(detail);
|
|
326
|
+
const retryAfter =
|
|
327
|
+
payload.error.retryAfter ??
|
|
328
|
+
payload.error.retry_after ??
|
|
329
|
+
payload.metadata?.['retry-after'] ??
|
|
330
|
+
payload.metadata?.retryAfter ??
|
|
331
|
+
null;
|
|
332
|
+
return cursorError(`Cursor ${connectCode}: ${detail}`, {
|
|
333
|
+
status: tooLarge ? 413 : CONNECT_CODE_STATUS[connectCode] || 0,
|
|
334
|
+
code: tooLarge ? 'request_too_large' : connectCode,
|
|
335
|
+
cursorCode: connectCode,
|
|
336
|
+
headers: payload.metadata,
|
|
337
|
+
retryAfter: firstValue(retryAfter),
|
|
338
|
+
detail,
|
|
269
339
|
});
|
|
270
340
|
} catch {
|
|
271
341
|
return cursorError('Cursor returned an invalid end-stream frame', { code: 'protocol_error' });
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from './cursor-wire-protobuf.mjs';
|
|
15
15
|
import {
|
|
16
16
|
assertCursorUserImages,
|
|
17
|
+
createCursorCredentialRefresher,
|
|
17
18
|
createCursorStreamWatchdog,
|
|
18
19
|
resolveCursorStreamTuning,
|
|
19
20
|
storeCursorBlob,
|
|
@@ -358,12 +359,16 @@ function createStreamResponse({
|
|
|
358
359
|
sessionId = '',
|
|
359
360
|
sawTurnEnded = false,
|
|
360
361
|
restart = null,
|
|
362
|
+
sleep = undefined,
|
|
363
|
+
onStageChange = null,
|
|
361
364
|
}) {
|
|
362
365
|
const id = newCompletionId();
|
|
363
366
|
// The bridge currently feeding the stream; a restart swaps it in place.
|
|
364
367
|
const live = { bridge, heartbeat };
|
|
365
368
|
let watchdog = null;
|
|
366
369
|
let cancelled = false;
|
|
370
|
+
// Cuts a pending retry backoff short when the caller cancels.
|
|
371
|
+
const cancelController = new AbortController();
|
|
367
372
|
const runs = { get: (runKey) => activeRuns.get(runKey), store: storeActiveRun, forget: forgetActiveRun };
|
|
368
373
|
const stream = new ReadableStream({
|
|
369
374
|
start(controller) {
|
|
@@ -395,12 +400,16 @@ function createStreamResponse({
|
|
|
395
400
|
run: { key, conversation, tools, cloudRule, modelParameters, maxMode, sessionId },
|
|
396
401
|
runs,
|
|
397
402
|
restart,
|
|
403
|
+
sleep,
|
|
404
|
+
onStageChange,
|
|
405
|
+
cancelSignal: cancelController.signal,
|
|
398
406
|
isCancelled: () => cancelled,
|
|
399
407
|
});
|
|
400
408
|
bridges.attach(bridge, heartbeat);
|
|
401
409
|
},
|
|
402
410
|
cancel(reason) {
|
|
403
411
|
cancelled = true;
|
|
412
|
+
cancelController.abort();
|
|
404
413
|
watchdog?.stop();
|
|
405
414
|
clearInterval(live.heartbeat);
|
|
406
415
|
const active = activeRuns.get(key);
|
|
@@ -459,7 +468,7 @@ function noUserMessageResponse() {
|
|
|
459
468
|
});
|
|
460
469
|
}
|
|
461
470
|
|
|
462
|
-
export async function handleChatCompletion(body, accessToken) {
|
|
471
|
+
export async function handleChatCompletion(body, accessToken, { onStageChange = null, getAccessToken = null } = {}) {
|
|
463
472
|
await warmCursorClientVersion();
|
|
464
473
|
const parsed = parseMessages(body.messages);
|
|
465
474
|
if (
|
|
@@ -504,6 +513,10 @@ export async function handleChatCompletion(body, accessToken) {
|
|
|
504
513
|
};
|
|
505
514
|
const requestBytes = buildRunRequest(runInput);
|
|
506
515
|
const { bridge, heartbeat } = startRun(accessToken, requestBytes);
|
|
516
|
+
// Every retry force-refreshes credentials and falls back to the previous
|
|
517
|
+
// token if the refresh fails. A rejected credential is only
|
|
518
|
+
// retried when the refresh produced a different token.
|
|
519
|
+
const credentials = createCursorCredentialRefresher(accessToken, getAccessToken);
|
|
507
520
|
return createStreamResponse({
|
|
508
521
|
bridge,
|
|
509
522
|
heartbeat,
|
|
@@ -515,11 +528,21 @@ export async function handleChatCompletion(body, accessToken) {
|
|
|
515
528
|
maxMode,
|
|
516
529
|
key,
|
|
517
530
|
sessionId,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
531
|
+
onStageChange,
|
|
532
|
+
restart: async ({ fromCheckpoint, reason, error }) => {
|
|
533
|
+
if (reason === 'throttle') {
|
|
534
|
+
// Cursor can pin resource_exhausted to one conversation id (conversation
|
|
535
|
+
// rotation after a rate limit): start a fresh one from the full
|
|
536
|
+
// history instead of the checkpoint it refused.
|
|
537
|
+
conversation.id = crypto.randomUUID();
|
|
538
|
+
conversation.checkpoint = null;
|
|
539
|
+
}
|
|
540
|
+
const token = await credentials.refresh(reason, error);
|
|
541
|
+
let bytes = requestBytes;
|
|
542
|
+
if (fromCheckpoint) bytes = buildRunRequest({ ...runInput, userText: '', userImages: [] });
|
|
543
|
+
else if (reason === 'throttle') bytes = buildRunRequest(runInput);
|
|
544
|
+
return startRun(token, bytes);
|
|
545
|
+
},
|
|
523
546
|
});
|
|
524
547
|
}
|
|
525
548
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from './openai-compat-wire.mjs';
|
|
11
11
|
import { preparePdfTextForProvider } from './media-normalization.mjs';
|
|
12
12
|
import { consumeCompatChatCompletionStream } from './openai-compat-stream.mjs';
|
|
13
|
+
import { retryAfterMsFromError } from './retry-classifier.mjs';
|
|
13
14
|
import { ensureChatToolPairs } from './lib/wire-pairing.mjs';
|
|
14
15
|
import { cursorTokenExpiry, exchangeCursorToken, resolveCursorOAuthAccessToken } from './cursor-auth.mjs';
|
|
15
16
|
import {
|
|
@@ -62,14 +63,24 @@ function toCursorToolChoice(toolChoice) {
|
|
|
62
63
|
async function responseError(response, label) {
|
|
63
64
|
const text = await response.text().catch(() => '');
|
|
64
65
|
let message = text;
|
|
66
|
+
let code = '';
|
|
65
67
|
try {
|
|
66
68
|
const parsed = JSON.parse(text);
|
|
67
69
|
message = parsed?.error?.message || parsed?.message || text;
|
|
70
|
+
code = String(parsed?.error?.code || parsed?.code || '').toLowerCase();
|
|
68
71
|
} catch {
|
|
69
72
|
/* plain text */
|
|
70
73
|
}
|
|
71
74
|
const error = new Error(`${label} request failed (${response.status})${message ? `: ${message}` : ''}`);
|
|
72
75
|
error.status = response.status;
|
|
76
|
+
error.httpStatus = response.status;
|
|
77
|
+
if (code) error.code = code;
|
|
78
|
+
const headers = Object.fromEntries(response.headers?.entries?.() ?? []);
|
|
79
|
+
if (Object.keys(headers).length) {
|
|
80
|
+
error.headers = headers;
|
|
81
|
+
const retryAfterMs = retryAfterMsFromError(error);
|
|
82
|
+
if (Number.isFinite(retryAfterMs)) error.retryAfterMs = retryAfterMs;
|
|
83
|
+
}
|
|
73
84
|
throw error;
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -535,6 +546,9 @@ class CursorProviderBase {
|
|
|
535
546
|
nativePdf = false;
|
|
536
547
|
|
|
537
548
|
static inputExcludesCache = false;
|
|
549
|
+
// The in-place stream retry budget is the whole recovery: a spent budget
|
|
550
|
+
// surfaces instead of being re-run by the outer recovery cycles.
|
|
551
|
+
retryPolicy = Object.freeze({ recoveryCycles: false });
|
|
538
552
|
name;
|
|
539
553
|
config;
|
|
540
554
|
_runtimeOverride;
|
|
@@ -622,7 +636,10 @@ class CursorProviderBase {
|
|
|
622
636
|
sendOpts.onStageChange?.('requesting');
|
|
623
637
|
} catch {}
|
|
624
638
|
try {
|
|
625
|
-
const response = await runtime.handleChatCompletion(body, accessToken
|
|
639
|
+
const response = await runtime.handleChatCompletion(body, accessToken, {
|
|
640
|
+
onStageChange: sendOpts.onStageChange,
|
|
641
|
+
getAccessToken: (options) => this._accessToken({ signal, ...options }),
|
|
642
|
+
});
|
|
626
643
|
try {
|
|
627
644
|
sendOpts.onStageChange?.('streaming');
|
|
628
645
|
} catch {}
|