From edc3acc3e2f20ad6ec6dcde7f6ff2b3b237d4ab2 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:27:35 +0100 Subject: [PATCH] fix(cursor): requeue user message on transient agent exit (auth, rate limit) (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cursor): requeue user message on transient agent exit (auth, rate limit) cursorLegacyRemoteLauncher.runMainLoop popped a user message off the queue before spawning `agent` and silently discarded it whenever `agent` exited non-zero (auth expiry, rate limit, transient network). The wrapper logged the failure at debug level only, never surfaced it to the web UI, and emitted `ready` as if a normal turn had ended. Capture stderr from the spawned process; classify exit-1 with a transient signature (Authentication required, rate limit, ETIMEDOUT, ECONNRESET, EAI_AGAIN) as recoverable; re-head the message via `queue.unshift`, surface a friendly banner via `sendSessionEvent({type:'message',...})`, and backoff ~2s before the loop picks it up again. Cap at 5 consecutive transient failures, after which the message is dropped with a clear "resolve and resend" event so we never spin forever on a genuinely broken auth. Non-transient non-zero exits also surface the stderr to the UI now (instead of only the local ring buffer), so a real crash is visible to the operator. Backoff is overridable via CURSOR_LEGACY_TRANSIENT_BACKOFF_MS for tests. Tests cover: success path, transient auth requeue + banner, rate-limit banner, non-transient crash surfaced without requeue, and the 5-failure drop cap. Co-authored-by: Cursor * fix(cursor): preserve slash-command isolation on requeue; wait for stderr flush Two findings from the cold-review bot on the requeue path: 1. `enqueueCursorUserMessage` uses `pushIsolated` for pass-through slash commands (e.g. `/compress`) so they never batch with sibling prompts. The transient-requeue path used plain `unshift`, which dropped the isolate bit and allowed the next collected batch to merge the slash command with a sibling - changing command semantics. Add `MessageQueue2.unshiftIsolated` and use it when the popped batch was isolated or when `parseCursorSpecialCommand` recognises the message. 2. `runAgentProcess` resolved on `child.on('exit', ...)`. Node may emit `exit` while the stderr pipe is still draining, so a fast "auth required" error printed-and-exited could be classified as non-transient with empty stderr and silently drop the user message - the exact bug this PR was supposed to fix. Resolve on `close` instead, which waits for stdio streams to flush. Adds a unit test that requeues `/compress` after a transient auth failure and asserts the second spawn still receives the slash command alone (not batched with a sibling). Co-authored-by: Cursor * fix(cursor): restrict transient retry to exit code 1 only Upstream codex review #823 (Minor): the helper treated any non-zero exit with matching stderr as transient, which could requeue a signal-killed (SIGTERM 143, SIGKILL 137) or crashed (SIGABRT 134) process whose stderr happens to contain a keyword like "rate limit". Documented contract is exit-1-for-transient; tighten the classifier accordingly. Adds regression test covering exit 143 + rate-limit stderr → no retry. Co-authored-by: Cursor * fix(cursor): clean up transientBackoff abort listener on timer completion Upstream codex review #823 (Minor): transientBackoff added an abort listener with { once: true } but only removed it when the abort fired. Because the launcher reuses one AbortController, repeated transient retries accumulated stale listeners until the next abort. Switch to a single completion path that clears the timer AND removes the abort listener whichever side wins. Co-authored-by: Cursor * fix(cursor): cap in-memory stderr capture at 8 KB Upstream codex review #823 (Minor): runAgentProcess accumulated every stderr chunk for the full child lifetime. A noisy `agent` failure could grow CLI process memory without bound even though only the first 400 chars are ever displayed. Cap the retained copy at 8 KB; debug log of the full stream is unchanged. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../cursor/cursorLegacyRemoteLauncher.test.ts | 295 +++++++++++++++--- cli/src/cursor/cursorLegacyRemoteLauncher.ts | 158 +++++++++- cli/src/utils/MessageQueue2.ts | 36 +++ 3 files changed, 440 insertions(+), 49 deletions(-) diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts index 46d30548..32d81162 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { PassThrough } from 'node:stream'; const spawnMock = vi.hoisted(() => vi.fn()); @@ -26,24 +27,62 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import { CursorSession } from './session'; import type { EnhancedMode } from './loop'; -function makeChild() { - const stdoutHandlers: Array<(chunk: string) => void> = []; - return { - stdout: { on: vi.fn((event: string, handler: (chunk: string) => void) => { - if (event === 'data') stdoutHandlers.push(handler); - }) }, - stderr: { on: vi.fn() }, +type ChildOptions = { + exitCode?: number | null; + stderr?: string; +}; + +function makeChild(opts: ChildOptions = {}) { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = { + stdout, + stderr, on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - if (event === 'exit') { - setImmediate(() => handler(0, null)); + if (event === 'close') { + setImmediate(() => { + if (opts.stderr) { + // emit synchronously so runAgentProcess captures it before + // the close handler resolves. + stderr.emit('data', Buffer.from(opts.stderr)); + } + handler(opts.exitCode ?? 0, null); + }); } }), emitStdout(line: string) { - for (const handler of stdoutHandlers) { - handler(`${line}\n`); - } + stdout.write(`${line}\n`); } }; + return child; +} + +function makeClient() { + return { + rpcHandlerManager: { registerHandler: vi.fn() }, + updateMetadata: vi.fn((handler: (m: Record) => Record) => { + handler({ path: '/tmp', host: 'h', flavor: 'cursor' }); + }), + sendSessionEvent: vi.fn(), + sendAgentMessage: vi.fn(), + keepAlive: vi.fn(), + emitMessagesConsumed: vi.fn() + }; +} + +function makeSession(queue: MessageQueue2, client: ReturnType): CursorSession { + return new CursorSession({ + api: {} as never, + client: client as never, + path: '/tmp/project', + logPath: '/tmp/log', + sessionId: 'legacy-id', + messageQueue: queue, + onModeChange: vi.fn(), + mode: 'remote', + startedBy: 'runner', + startingMode: 'remote' + }); } describe('cursorLegacyRemoteLauncher', () => { @@ -51,6 +90,11 @@ describe('cursorLegacyRemoteLauncher', () => { spawnMock.mockReset(); process.stdin.isTTY = false; process.stdout.isTTY = false; + process.env.CURSOR_LEGACY_TRANSIENT_BACKOFF_MS = '0'; + }); + + afterEach(() => { + delete process.env.CURSOR_LEGACY_TRANSIENT_BACKOFF_MS; }); it('spawns agent with stream-json and trust, not acp', async () => { @@ -61,30 +105,8 @@ describe('cursorLegacyRemoteLauncher', () => { queue.push('hello', { permissionMode: 'default' }); queue.close(); - const metadataUpdates: unknown[] = []; - const client = { - rpcHandlerManager: { registerHandler: vi.fn() }, - updateMetadata: vi.fn((handler: (m: Record) => Record) => { - metadataUpdates.push(handler({ path: '/tmp', host: 'h', flavor: 'cursor' })); - }), - sendSessionEvent: vi.fn(), - sendAgentMessage: vi.fn(), - keepAlive: vi.fn(), - emitMessagesConsumed: vi.fn() - }; - - const session = new CursorSession({ - api: {} as never, - client: client as never, - path: '/tmp/project', - logPath: '/tmp/log', - sessionId: 'legacy-id', - messageQueue: queue, - onModeChange: vi.fn(), - mode: 'remote', - startedBy: 'runner', - startingMode: 'remote' - }); + const client = makeClient(); + const session = makeSession(queue, client); const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); await cursorLegacyRemoteLauncher(session); @@ -97,10 +119,203 @@ describe('cursorLegacyRemoteLauncher', () => { expect(args).toContain('--resume'); expect(args).toContain('legacy-id'); expect(args).not.toContain('acp'); + }); - expect(metadataUpdates[0]).toEqual(expect.objectContaining({ - cursorSessionId: 'legacy-id', - cursorSessionProtocol: 'stream-json' + it('requeues the user message and surfaces an auth banner when agent exits with auth-required stderr', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return makeChild({ + exitCode: 1, + stderr: "Error: Authentication required. Please run 'agent login' first\n" + }); + } + queue.close(); + return makeChild({ exitCode: 0 }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(2); + const messages = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messages).toHaveLength(1); + expect(messages[0].message).toContain('Cursor authentication expired'); + expect(messages[0].message).toContain("'agent login'"); + expect(messages[0].message).toContain('queued and will retry'); + + const firstPrompt = spawnMock.mock.calls[0]?.[1] as string[]; + const secondPrompt = spawnMock.mock.calls[1]?.[1] as string[]; + const pIndex1 = firstPrompt.indexOf('-p'); + const pIndex2 = secondPrompt.indexOf('-p'); + expect(firstPrompt[pIndex1 + 1]).toBe('do thing'); + expect(secondPrompt[pIndex2 + 1]).toBe('do thing'); + }); + + it('uses a rate-limit-specific banner for rate limit stderr', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return makeChild({ + exitCode: 1, + stderr: 'Error: rate limit exceeded, please retry later\n' + }); + } + queue.close(); + return makeChild({ exitCode: 0 }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + const messages = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messages).toHaveLength(1); + expect(messages[0].message).toContain('rate limit'); + expect(messages[0].message).toContain('queued and will retry'); + }); + + it('does not requeue when stderr is non-transient (real crash); surfaces error and emits ready', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + queue.close(); + + spawnMock.mockReturnValue(makeChild({ + exitCode: 134, + stderr: 'fatal: Segmentation fault\n' })); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const messageEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messageEvents).toHaveLength(1); + expect(messageEvents[0].message).toContain('Agent exited (134)'); + expect(messageEvents[0].message).toContain('Segmentation fault'); + expect(messageEvents[0].message).not.toContain('queued and will retry'); + + const readyEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'ready'); + expect(readyEvents).toHaveLength(1); + }); + + it('does not retry signal-killed processes even if stderr contains a transient keyword', async () => { + // SIGTERM → exit 143; stderr happens to mention rate limit. Should NOT be + // classified transient because the documented contract is exit-1-only. + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + queue.close(); + + spawnMock.mockReturnValue(makeChild({ + exitCode: 143, + stderr: 'rate limit hit; aborting due to SIGTERM\n' + })); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const messageEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + expect(messageEvents).toHaveLength(1); + expect(messageEvents[0].message).toContain('Agent exited (143)'); + expect(messageEvents[0].message).not.toContain('queued and will retry'); + }); + + it('preserves isolation when requeueing a slash command after a transient failure', async () => { + const queue = new MessageQueue2(() => 'm'); + // /compress is a pass-through slash command; enqueueCursorUserMessage uses + // pushIsolated for these so they never batch with sibling prompts. + queue.pushIsolated('/compress', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call === 1) { + return makeChild({ + exitCode: 1, + stderr: "Error: Authentication required. Please run 'agent login' first\n" + }); + } + queue.close(); + return makeChild({ exitCode: 0 }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(2); + // Confirm the queue still flagged the requeued item as isolated + // (the second collectBatch saw it alone with isolate=true). + const secondPrompt = spawnMock.mock.calls[1]?.[1] as string[]; + const pIdx = secondPrompt.indexOf('-p'); + expect(secondPrompt[pIdx + 1]).toBe('/compress'); + }); + + it('drops the message after MAX_CONSECUTIVE_TRANSIENT_FAILURES consecutive transient failures', async () => { + const queue = new MessageQueue2(() => 'm'); + queue.push('do thing', { permissionMode: 'default' }); + + let call = 0; + spawnMock.mockImplementation(() => { + call += 1; + if (call >= 5) { + queue.close(); + } + return makeChild({ + exitCode: 1, + stderr: "Error: Authentication required. Please run 'agent login' first\n" + }); + }); + + const client = makeClient(); + const session = makeSession(queue, client); + + const { cursorLegacyRemoteLauncher } = await import('./cursorLegacyRemoteLauncher'); + await cursorLegacyRemoteLauncher(session); + + expect(spawnMock).toHaveBeenCalledTimes(5); + + const messageEvents = client.sendSessionEvent.mock.calls + .map((c) => c[0]) + .filter((e: any) => e.type === 'message'); + // 4 transient retry banners + 1 drop banner = 5 + expect(messageEvents).toHaveLength(5); + const banners = messageEvents.map((e: any) => e.message); + expect(banners.filter((m: string) => m.includes('queued and will retry'))).toHaveLength(4); + const drop = banners.find((m: string) => m.includes('5 times in a row')); + expect(drop).toBeDefined(); + expect(drop).toContain('Dropping the queued message'); }); }); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 5d24ab9d..b8f80e85 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -11,6 +11,7 @@ import { type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; import type { CursorSession } from './session'; +import type { EnhancedMode } from './loop'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. @@ -19,6 +20,54 @@ import type { CursorStreamEvent } from './utils/cursorLegacyEventConverter'; import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorLegacyEventConverter'; import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands'; +// Transient `agent` failures (auth expiry, rate limits, transient network) come back +// as exit code 1 with a recognisable stderr signature. We requeue and retry instead +// of silently swallowing the user message. +const TRANSIENT_STDERR_PATTERN = /authentication required|please run ['"]?agent login['"]?|rate limit|ETIMEDOUT|ECONNRESET|EAI_AGAIN/i; +const AUTH_STDERR_PATTERN = /authentication required|please run ['"]?agent login['"]?/i; +const RATE_LIMIT_STDERR_PATTERN = /rate limit/i; +const DEFAULT_TRANSIENT_BACKOFF_MS = 2_000; +const MAX_CONSECUTIVE_TRANSIENT_FAILURES = 5; +const STDERR_DISPLAY_LIMIT = 400; +// In-memory stderr cap. Display only uses STDERR_DISPLAY_LIMIT chars; this is a +// safety bound so a chatty `agent` failure cannot balloon CLI process memory. +const STDERR_CAPTURE_LIMIT = 8_192; + +function getTransientBackoffMs(): number { + const raw = process.env.CURSOR_LEGACY_TRANSIENT_BACKOFF_MS; + if (raw === undefined) return DEFAULT_TRANSIENT_BACKOFF_MS; + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed < 0) return DEFAULT_TRANSIENT_BACKOFF_MS; + return parsed; +} + +function isTransientAgentError(exitCode: number, stderr: string): boolean { + // Known transient failures (auth expiry, rate limit, transient network) + // all come back as exit code 1. Keep the retry path narrow to that contract; + // signal-kills (137 SIGKILL, 143 SIGTERM) and crashes (134 SIGABRT, etc.) + // should never auto-retry even if their stderr happens to contain a matching + // keyword. + return exitCode === 1 && TRANSIENT_STDERR_PATTERN.test(stderr); +} + +function truncateStderrForDisplay(stderr: string): string { + const trimmed = stderr.trim(); + if (!trimmed) return '(no stderr)'; + return trimmed.length > STDERR_DISPLAY_LIMIT + ? `${trimmed.slice(0, STDERR_DISPLAY_LIMIT)}...` + : trimmed; +} + +function friendlyTransientMessage(exitCode: number, stderr: string): string { + if (AUTH_STDERR_PATTERN.test(stderr)) { + return "Cursor authentication expired. Re-run 'agent login' or set CURSOR_API_KEY. Your message is queued and will retry automatically."; + } + if (RATE_LIMIT_STDERR_PATTERN.test(stderr)) { + return 'Cursor rate limit hit. Your message is queued and will retry automatically.'; + } + return `Cursor agent failed transiently (exit ${exitCode}). Your message is queued and will retry automatically.`; +} + function buildAgentArgs(opts: { message: string; cwd: string; @@ -57,6 +106,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { private readonly session: CursorSession; private abortController = new AbortController(); private displayPermissionMode: string | null = null; + private consecutiveTransientFailures = 0; constructor(session: CursorSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -102,7 +152,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { break; } - const { message, mode } = batch; + const { message, mode, isolate: batchIsolated } = batch; const specialCommand = parseCursorSpecialCommand(message); const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); @@ -128,7 +178,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { session.onThinkingChange(true); try { - const exitCode = await this.runAgentProcess(args, session.path, (event) => { + const { exitCode, stderr } = await this.runAgentProcess(args, session.path, (event) => { if (event.type === 'system' && event.subtype === 'init' && event.session_id) { cursorSessionId = event.session_id; session.onSessionFoundWithProtocol(event.session_id, 'stream-json'); @@ -162,11 +212,19 @@ class CursorRemoteLauncher extends RemoteLauncherBase { } }); - if (exitCode !== 0 && exitCode !== null) { - logger.debug(`[cursor-remote] Agent exited with code ${exitCode}`); - messageBuffer.addMessage(`Agent exited with code ${exitCode}`, 'status'); + if (exitCode === 0 || exitCode === null) { + this.consecutiveTransientFailures = 0; + } else if (isTransientAgentError(exitCode, stderr)) { + await this.handleTransientAgentFailure(exitCode, stderr, message, mode, batchIsolated); + } else { + this.consecutiveTransientFailures = 0; + const errMsg = `Agent exited (${exitCode}): ${truncateStderrForDisplay(stderr)}`; + logger.warn(`[cursor-remote] ${errMsg}`); + session.sendSessionEvent({ type: 'message', message: errMsg }); + messageBuffer.addMessage(errMsg, 'status'); } } catch (error) { + this.consecutiveTransientFailures = 0; logger.warn('[cursor-remote] Agent run failed', error); const errMsg = error instanceof Error ? error.message : String(error); session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); @@ -184,7 +242,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase { args: string[], cwd: string, onEvent: (event: ReturnType & object) => void - ): Promise { + ): Promise<{ exitCode: number | null; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn('agent', args, { cwd, @@ -194,9 +252,11 @@ class CursorRemoteLauncher extends RemoteLauncherBase { windowsHide: process.platform === 'win32' }); + let stderrCapture = ''; + const abortHandler = () => { killProcessByChildProcess(child, false).catch(() => {}); - resolve(null); + resolve({ exitCode: null, stderr: stderrCapture }); }; this.abortController.signal.addEventListener('abort', abortHandler); @@ -209,9 +269,14 @@ class CursorRemoteLauncher extends RemoteLauncherBase { reject(err); }); - child.on('exit', (code, signal) => { + // `close` (not `exit`) waits for the stdio streams to flush before + // firing. Otherwise stderr from an agent that prints + exits quickly + // (e.g. "Authentication required" → exit 1) can arrive after we + // already classified the failure, turning a transient error into a + // dropped message. + child.on('close', (code, signal) => { cleanup(); - resolve(code); + resolve({ exitCode: code, stderr: stderrCapture }); }); const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); @@ -224,6 +289,9 @@ class CursorRemoteLauncher extends RemoteLauncherBase { child.stderr?.on('data', (chunk) => { const text = chunk.toString(); + if (stderrCapture.length < STDERR_CAPTURE_LIMIT) { + stderrCapture += text.slice(0, STDERR_CAPTURE_LIMIT - stderrCapture.length); + } if (text.trim()) { logger.debug('[cursor-remote] agent stderr:', text.trim()); } @@ -231,6 +299,78 @@ class CursorRemoteLauncher extends RemoteLauncherBase { }); } + private async handleTransientAgentFailure( + exitCode: number, + stderr: string, + message: string, + mode: EnhancedMode, + batchIsolated: boolean + ): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + this.consecutiveTransientFailures += 1; + + if (this.consecutiveTransientFailures >= MAX_CONSECUTIVE_TRANSIENT_FAILURES) { + const summary = truncateStderrForDisplay(stderr); + const dropMsg = `Cursor agent failed ${MAX_CONSECUTIVE_TRANSIENT_FAILURES} times in a row (${summary}). Dropping the queued message; resolve the issue ('agent login', wait out rate limit, etc.) and resend.`; + logger.warn( + `[cursor-remote] transient agent failures hit cap (${MAX_CONSECUTIVE_TRANSIENT_FAILURES}); dropping message`, + { exitCode, stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) } + ); + session.sendSessionEvent({ type: 'message', message: dropMsg }); + messageBuffer.addMessage(dropMsg, 'status'); + this.consecutiveTransientFailures = 0; + return; + } + + logger.warn( + '[cursor-remote] transient agent failure, requeueing user message', + { + exitCode, + attempt: this.consecutiveTransientFailures, + stderr: stderr.slice(0, STDERR_DISPLAY_LIMIT) + } + ); + // Preserve isolation when the original batch was isolated (e.g. a + // pass-through slash command queued via pushIsolated). Without this the + // requeued command could be batched with a sibling prompt on retry and + // change semantics. parseCursorSpecialCommand is the same gate + // enqueueCursorUserMessage uses to decide isolation in the first place. + const requeueIsolated = batchIsolated || parseCursorSpecialCommand(message).type !== null; + if (requeueIsolated) { + session.queue.unshiftIsolated(message, mode); + } else { + session.queue.unshift(message, mode); + } + const friendly = friendlyTransientMessage(exitCode, stderr); + session.sendSessionEvent({ type: 'message', message: friendly }); + messageBuffer.addMessage(friendly, 'status'); + await this.transientBackoff(getTransientBackoffMs()); + } + + private async transientBackoff(ms: number): Promise { + if (ms <= 0) return; + const signal = this.abortController.signal; + if (signal.aborted) return; + await new Promise((resolve) => { + let timer: ReturnType | null = null; + // Single completion path so the abort listener is always removed, + // whether the timer or the abort wins. Without this, repeated + // transient retries on the same AbortController accumulate stale + // listeners until the next abort fires them in bulk. + const finish = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener('abort', finish); + resolve(); + }; + timer = setTimeout(finish, ms); + signal.addEventListener('abort', finish, { once: true }); + }); + } + private applyDisplayMode(permissionMode: string | undefined): void { if (permissionMode && permissionMode !== this.displayPermissionMode) { this.displayPermissionMode = permissionMode; diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index fea79030..54d0e5a3 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -219,6 +219,42 @@ export class MessageQueue2 { logger.debug(`[MessageQueue2] unshift() completed. Queue size: ${this.queue.length}`); } + /** + * Push a message to the beginning of the queue with isolation preserved. + * Mirrors `pushIsolated` but inserts at the head. Use this when requeueing a + * batch that was originally collected under isolation (e.g. a slash command + * that failed transiently and must retry without batching against sibling + * prompts). + */ + unshiftIsolated(message: string, mode: T, localId?: string): void { + if (this.closed) { + throw new Error('Cannot unshift to closed queue'); + } + + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] unshiftIsolated() called with mode hash: ${modeHash}`); + + this.queue.unshift({ + message, + mode, + modeHash, + localId, + isolate: true + }); + + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + + if (this.waiter) { + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + + logger.debug(`[MessageQueue2] unshiftIsolated() completed. Queue size: ${this.queue.length}`); + } + /** * Remove the first queued message that matches the given localId. * Returns true if a message was removed, false if not found.