From 12908891c24592b4d33a6ba0902edd425d1a9e7e Mon Sep 17 00:00:00 2001 From: RawToast <8013000+RawToast@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:56:04 +0900 Subject: [PATCH 1/3] Restart bridge on failure --- .../cursor-sdk-local-agent-bridge.test.mjs | 31 +++++ scripts/cursor-sdk-local-agent-bridge.mjs | 111 ++++++++++++++++-- server.ts | 28 +++-- 3 files changed, 154 insertions(+), 16 deletions(-) diff --git a/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs b/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs index a1057ec..f108330 100644 --- a/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs +++ b/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs @@ -11,6 +11,7 @@ import { localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, + isOpaqueSDKRunFailure, isRetryableSDKRunError, normalizeModel, normalizeSDKToolCall, @@ -63,6 +64,36 @@ describe("Cursor SDK local-agent bridge", () => { }) }) + it("detects opaque SDK run failures that need agent resume or bridge restart", () => { + const opaque = Object.assign(new Error("Cursor SDK run failed"), { + code: "cursor_sdk_error", + isRetryable: true, + rawMessage: "", + cause: { status: "error", code: "", message: "", retryable: true }, + }) + expect(isOpaqueSDKRunFailure(opaque)).toBe(true) + + const capacity = Object.assign(new Error("Server at capacity"), { + code: "cursor_sdk_error", + isRetryable: true, + rawMessage: "Server at capacity", + cause: { + status: "error", + code: "unavailable", + message: "Server at capacity", + retryable: true, + }, + }) + expect(isOpaqueSDKRunFailure(capacity)).toBe(false) + + const auth = Object.assign(new Error("Missing or invalid authorization"), { + code: "unauthorized", + isRetryable: false, + status: 401, + }) + expect(isOpaqueSDKRunFailure(auth)).toBe(false) + }) + it("surfaces Cursor SDK authentication failures as unauthorized API errors", () => { const error = Object.assign(new Error("Error"), { name: "AuthenticationError", diff --git a/scripts/cursor-sdk-local-agent-bridge.mjs b/scripts/cursor-sdk-local-agent-bridge.mjs index e9229ed..1a872ca 100644 --- a/scripts/cursor-sdk-local-agent-bridge.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.mjs @@ -32,6 +32,15 @@ const agentRunQueues = new Map() /** @type {Map>} */ const activeClientToolCaptures = new Map() const forceNextRunAgentKeys = new Set() +/** @type {Map} */ +const pendingResumeAgentIds = new Map() +const opaqueFailureRestartThreshold = parseInteger( + process.env.CURSOR_SDK_BRIDGE_OPAQUE_FAILURE_RESTART, + 1, +) +const timeoutSettleMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_TIMEOUT_SETTLE_MS, 2_000) +let consecutiveOpaqueFailures = 0 +let bridgeRestartScheduled = false let server = null if (isMainModule()) { @@ -52,6 +61,7 @@ export { localAgentCreateOptions, localAgentSendOptions, isForwardableSDKToolCall, + isOpaqueSDKRunFailure, isRetryableSDKRunError, normalizeSDKToolCall, normalizeModel, @@ -211,6 +221,7 @@ async function runLocalAgentUnlocked(input, onEvent) { let activeRun = null let emittedEvent = false let timer = null + let timedOut = false const emit = onEvent ? (event) => { emittedEvent = true @@ -226,6 +237,7 @@ async function runLocalAgentUnlocked(input, onEvent) { ) const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + timedOut = true const error = new HttpError("Cursor SDK bridge run timed out.", 504, "cursor_sdk_timeout") reject(error) if (activeRun) { @@ -235,13 +247,26 @@ async function runLocalAgentUnlocked(input, onEvent) { }) try { - return await Promise.race([work, timeout]) + const output = await Promise.race([work, timeout]) + noteSdkRunSuccess() + return output } catch (error) { - work.catch(() => {}) + if (timedOut) { + // Keep the per-agent exclusive lock until the abandoned run settles, or + // the next send() on the same cached agent races a still-live run. + if (activeRun) activeRun.cancel().catch(() => {}) + evictCachedAgent(input, { resume: true }) + await Promise.race([work.catch(() => {}), sleep(timeoutSettleMs)]) + } else { + work.catch(() => {}) + } const shouldRetry = attempt < maxRunRetries && !emittedEvent && isRetryableSDKRunError(error) - if (!shouldRetry) throw error + if (!shouldRetry) { + noteSdkRunFailure(error) + throw error + } if (activeRun) activeRun.cancel().catch(() => {}) - evictCachedAgent(input) + evictCachedAgent(input, { resume: isOpaqueSDKRunFailure(error) }) console.warn( `Retrying Cursor SDK run after retryable upstream error (${attempt + 1}/${maxRunRetries}).`, ) @@ -384,12 +409,30 @@ async function getAgent(input) { return { agent: cached.agent, cacheKey, cached: true } } - const agent = await Agent.create(localAgentCreateOptions(input)) + const agent = await createOrResumeAgent(input, cacheKey) agentCache.set(cacheKey, { agent, touchedAt: Date.now() }) evictAgents() return { agent, cacheKey, cached: false } } +async function createOrResumeAgent(input, cacheKey) { + const options = localAgentCreateOptions(input) + const resumeId = pendingResumeAgentIds.get(cacheKey) + if (resumeId) { + pendingResumeAgentIds.delete(cacheKey) + try { + // Cursor's recommended recovery for bare local-agent status=error: + // close the handle, then Agent.resume(agentId) instead of create(). + return await Agent.resume(resumeId, options) + } catch (error) { + console.warn( + `Cursor SDK Agent.resume failed after opaque error; falling back to Agent.create (${error?.message || error}).`, + ) + } + } + return Agent.create(options) +} + function evictAgent(cacheKey, agent) { const cached = agentCache.get(cacheKey) if (cached?.agent === agent) { @@ -401,10 +444,17 @@ function evictAgent(cacheKey, agent) { } catch {} } -function evictCachedAgent(input) { +function evictCachedAgent(input, options = {}) { const cacheKey = agentCacheKey(input) const cached = agentCache.get(cacheKey) - if (cached) evictAgent(cacheKey, cached.agent) + if (!cached) return + const agentId = cached.agent?.agentId + if (options.resume === true && typeof agentId === "string" && agentId) { + pendingResumeAgentIds.set(cacheKey, agentId) + } else { + pendingResumeAgentIds.delete(cacheKey) + } + evictAgent(cacheKey, cached.agent) } function registerActiveClientToolCapture(cacheKey, handler) { @@ -2592,7 +2642,15 @@ function sdkRunFailureError(result) { error.rawMessage = summary.message error.isRetryable = summary.retryable error.cause = summary - console.warn(`Cursor SDK run returned error status${summary.code ? ` (${summary.code})` : ""}.`) + const detailParts = [] + if (summary.code) detailParts.push(`code=${summary.code}`) + if (summary.message) detailParts.push(`message=${summary.message}`) + detailParts.push(summary.retryable ? "retryable" : "non-retryable") + if (result?.id) detailParts.push(`run=${result.id}`) + const detail = detailParts.join(", ") + console.warn( + `Cursor SDK run returned error status${detail ? ` (${detail})` : " (opaque, no code/message)"}.`, + ) return error } @@ -2614,6 +2672,43 @@ function sdkRunFailureSummary(result) { } } +function isOpaqueSDKRunFailure(error) { + if (!error || error.isRetryable !== true || isAuthenticationSDKError(error)) return false + const summary = isRecord(error.cause) ? error.cause : null + if (summary?.status === "error" && !summary.message && !summary.code) return true + + const message = firstNonEmptyString(error.rawMessage, error.message) + if (message && message !== "Cursor SDK run failed") return false + if (error.code && error.code !== "cursor_sdk_error") return false + // Generic cursor_sdk_error with no upstream code is treated as opaque. + return !firstNonEmptyString(summary?.code) +} + +function noteSdkRunSuccess() { + consecutiveOpaqueFailures = 0 +} + +function noteSdkRunFailure(error) { + if (isOpaqueSDKRunFailure(error)) { + consecutiveOpaqueFailures += 1 + if (consecutiveOpaqueFailures < opaqueFailureRestartThreshold) return + scheduleBridgeRestart( + `opaque Cursor SDK error status x${consecutiveOpaqueFailures} (known local-agent stuck state; process restart recovers)`, + ) + return + } + if (isAuthenticationSDKError(error)) consecutiveOpaqueFailures = 0 +} + +function scheduleBridgeRestart(reason) { + if (bridgeRestartScheduled || !isMainModule()) return + bridgeRestartScheduled = true + console.error(`Restarting Cursor SDK bridge: ${reason}`) + setTimeout(() => { + void closeAndExit(1) + }, 50).unref?.() +} + function firstRecord(...values) { return values.find((value) => isRecord(value)) || {} } diff --git a/server.ts b/server.ts index 7f93168..d11d5b0 100644 --- a/server.ts +++ b/server.ts @@ -63,17 +63,29 @@ async function startBridge(): Promise { type SpawnedProcess = ReturnType function spawnBridgeSubprocess(runtime: string): () => void { let stopped = false - let child = Bun.spawn([runtime, bridgeScript], { stdout: "inherit", stderr: "inherit" }) + const bridgeHost = process.env.CURSOR_SDK_BRIDGE_HOST || "127.0.0.1" + const bridgePort = process.env.CURSOR_SDK_BRIDGE_PORT || "8792" + const healthUrl = `http://${bridgeHost}:${bridgePort}/health` + const spawnChild = (): SpawnedProcess => + Bun.spawn([runtime, bridgeScript], { stdout: "inherit", stderr: "inherit" }) - const superviseExit = (proc: SpawnedProcess) => { - void proc.exited.then((code) => { + let child = spawnChild() + + function superviseExit(proc: SpawnedProcess): void { + void proc.exited.then(async (code) => { if (stopped) return console.error(`SDK bridge exited with code ${code}; restarting in 1s.`) - setTimeout(() => { - if (stopped) return - child = Bun.spawn([runtime, bridgeScript], { stdout: "inherit", stderr: "inherit" }) - superviseExit(child) - }, 1000) + await new Promise((resolve) => setTimeout(resolve, 1000)) + if (stopped) return + child = spawnChild() + superviseExit(child) + try { + await waitForBridgeHealth(healthUrl) + console.error("SDK bridge restarted and is healthy.") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`SDK bridge failed to become healthy after restart: ${message}`) + } }) } superviseExit(child) From 93a7ee8348300233f6ee7a26192b0dc4b5e7d6a2 Mon Sep 17 00:00:00 2001 From: RawToast <8013000+RawToast@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:42:44 +0900 Subject: [PATCH 2/3] abc --- .../cursor-sdk-local-agent-bridge.test.mjs | 24 ++++++++++++++ scripts/cursor-sdk-local-agent-bridge.mjs | 31 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs b/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs index f108330..4b3ba85 100644 --- a/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs +++ b/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs @@ -13,6 +13,7 @@ import { isForwardableSDKToolCall, isOpaqueSDKRunFailure, isRetryableSDKRunError, + isStaleSdkAuthFailure, normalizeModel, normalizeSDKToolCall, openAiError, @@ -37,6 +38,29 @@ describe("Cursor SDK local-agent bridge", () => { expect(isRetryableSDKRunError({ status: 401, message: "Unauthorized" })).toBe(false) }) + it("treats stale SDK auth token failures as retryable process recovery, not bad API keys", () => { + const staleMessage = + "Authentication error If you are logged in, try logging out and back in." + expect(isStaleSdkAuthFailure(new Error(staleMessage))).toBe(true) + expect(isStaleSdkAuthFailure({ code: "ERROR_NOT_LOGGED_IN" })).toBe(true) + expect(isRetryableSDKRunError(new Error(staleMessage))).toBe(true) + expect(isRetryableSDKRunError({ code: "ERROR_NOT_LOGGED_IN", isRetryable: false })).toBe(true) + expect( + sdkRunFailureSummary({ + status: "error", + error: { message: staleMessage, code: "ERROR_NOT_LOGGED_IN" }, + }), + ).toMatchObject({ + message: staleMessage, + retryable: true, + }) + expect(statusFromError(new Error(staleMessage))).toBe(503) + expect(statusFromError(Object.assign(new Error(staleMessage), { name: "AuthenticationError" }))).toBe( + 503, + ) + expect(isRetryableSDKRunError(new Error("Missing or invalid authorization"))).toBe(false) + }) + it("treats opaque SDK error results as retryable but preserves explicit auth failures", () => { expect(sdkRunFailureSummary({ status: "error" })).toMatchObject({ message: "", diff --git a/scripts/cursor-sdk-local-agent-bridge.mjs b/scripts/cursor-sdk-local-agent-bridge.mjs index 1a872ca..6278ce9 100644 --- a/scripts/cursor-sdk-local-agent-bridge.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.mjs @@ -63,6 +63,7 @@ export { isForwardableSDKToolCall, isOpaqueSDKRunFailure, isRetryableSDKRunError, + isStaleSdkAuthFailure, normalizeSDKToolCall, normalizeModel, openAiError, @@ -266,7 +267,11 @@ async function runLocalAgentUnlocked(input, onEvent) { throw error } if (activeRun) activeRun.cancel().catch(() => {}) - evictCachedAgent(input, { resume: isOpaqueSDKRunFailure(error) }) + // Opaque errors and stale exchanged-token auth both recover via resume/restart, + // not by treating the caller's API key as invalid. + evictCachedAgent(input, { + resume: isOpaqueSDKRunFailure(error) || isStaleSdkAuthFailure(error), + }) console.warn( `Retrying Cursor SDK run after retryable upstream error (${attempt + 1}/${maxRunRetries}).`, ) @@ -2600,6 +2605,9 @@ function isBenignPipeError(error) { } function isRetryableSDKRunError(error) { + // SDK marks stale local-agent token/connection failures as non-retryable auth, + // but resume/restart recovers them with the same API key. + if (isStaleSdkAuthFailure(error)) return true const values = flattenErrorValues(error) if (values.some((value) => value?.isRetryable === true)) return true if ( @@ -2632,6 +2640,18 @@ function isRetryableSDKRunError(error) { ) } +function isStaleSdkAuthFailure(error) { + return flattenErrorValues(error).some((value) => { + const message = String(value?.message || value?.rawMessage || value?.error || "").toLowerCase() + const code = String(value?.code || "").toLowerCase() + return ( + code === "error_not_logged_in" || + message.includes("error_not_logged_in") || + message.includes("try logging out and back in") + ) + }) +} + function sdkRunFailureError(result) { const summary = sdkRunFailureSummary(result) const error = new HttpError( @@ -2689,11 +2709,14 @@ function noteSdkRunSuccess() { } function noteSdkRunFailure(error) { - if (isOpaqueSDKRunFailure(error)) { + if (isOpaqueSDKRunFailure(error) || isStaleSdkAuthFailure(error)) { consecutiveOpaqueFailures += 1 if (consecutiveOpaqueFailures < opaqueFailureRestartThreshold) return + const kind = isStaleSdkAuthFailure(error) + ? "stale SDK auth token/connection" + : "opaque Cursor SDK error status" scheduleBridgeRestart( - `opaque Cursor SDK error status x${consecutiveOpaqueFailures} (known local-agent stuck state; process restart recovers)`, + `${kind} x${consecutiveOpaqueFailures} (process restart recovers; API key is usually still valid)`, ) return } @@ -2789,6 +2812,8 @@ function codeFromError(error, status) { } function isAuthenticationSDKError(error) { + // Stale exchanged-token failures look like auth but the API key is still valid. + if (isStaleSdkAuthFailure(error)) return false return flattenErrorValues(error).some((value) => { const name = String(value?.name || "").toLowerCase() const code = String(value?.code || "").toLowerCase() From fe9b6af312b59dc8bf13b6f7314efad02abf1a58 Mon Sep 17 00:00:00 2001 From: RawToast <8013000+RawToast@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:45:20 +0900 Subject: [PATCH 3/3] fix: evict cached agents on final SDK run failures Format stale-auth tests for oxfmt and reset opaque-failure counters on any non-opaque error so exhausted retries cannot reuse broken cached agents. --- scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs | 9 ++++----- scripts/cursor-sdk-local-agent-bridge.mjs | 7 ++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs b/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs index 4b3ba85..fbd5343 100644 --- a/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs +++ b/scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs @@ -39,8 +39,7 @@ describe("Cursor SDK local-agent bridge", () => { }) it("treats stale SDK auth token failures as retryable process recovery, not bad API keys", () => { - const staleMessage = - "Authentication error If you are logged in, try logging out and back in." + const staleMessage = "Authentication error If you are logged in, try logging out and back in." expect(isStaleSdkAuthFailure(new Error(staleMessage))).toBe(true) expect(isStaleSdkAuthFailure({ code: "ERROR_NOT_LOGGED_IN" })).toBe(true) expect(isRetryableSDKRunError(new Error(staleMessage))).toBe(true) @@ -55,9 +54,9 @@ describe("Cursor SDK local-agent bridge", () => { retryable: true, }) expect(statusFromError(new Error(staleMessage))).toBe(503) - expect(statusFromError(Object.assign(new Error(staleMessage), { name: "AuthenticationError" }))).toBe( - 503, - ) + expect( + statusFromError(Object.assign(new Error(staleMessage), { name: "AuthenticationError" })), + ).toBe(503) expect(isRetryableSDKRunError(new Error("Missing or invalid authorization"))).toBe(false) }) diff --git a/scripts/cursor-sdk-local-agent-bridge.mjs b/scripts/cursor-sdk-local-agent-bridge.mjs index 6278ce9..772e291 100644 --- a/scripts/cursor-sdk-local-agent-bridge.mjs +++ b/scripts/cursor-sdk-local-agent-bridge.mjs @@ -264,6 +264,11 @@ async function runLocalAgentUnlocked(input, onEvent) { const shouldRetry = attempt < maxRunRetries && !emittedEvent && isRetryableSDKRunError(error) if (!shouldRetry) { noteSdkRunFailure(error) + if (!timedOut) { + evictCachedAgent(input, { + resume: isOpaqueSDKRunFailure(error) || isStaleSdkAuthFailure(error), + }) + } throw error } if (activeRun) activeRun.cancel().catch(() => {}) @@ -2720,7 +2725,7 @@ function noteSdkRunFailure(error) { ) return } - if (isAuthenticationSDKError(error)) consecutiveOpaqueFailures = 0 + consecutiveOpaqueFailures = 0 } function scheduleBridgeRestart(reason) {