fix(cursor): requeue user message on transient agent exit (auth, rate limit) (#823)

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-08 13:27:35 +08:00
committed by GitHub
co-authored by Cursor
parent 9349acba62
commit edc3acc3e2
3 changed files with 440 additions and 49 deletions
+255 -40
View File
@@ -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<string, unknown>) => Record<string, unknown>) => {
handler({ path: '/tmp', host: 'h', flavor: 'cursor' });
}),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
emitMessagesConsumed: vi.fn()
};
}
function makeSession(queue: MessageQueue2<EnhancedMode>, client: ReturnType<typeof makeClient>): 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<string, unknown>) => Record<string, unknown>) => {
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<EnhancedMode>(() => '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<EnhancedMode>(() => '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<EnhancedMode>(() => '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<EnhancedMode>(() => '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<EnhancedMode>(() => '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<EnhancedMode>(() => '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');
});
});
+149 -9
View File
@@ -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<typeof parseCursorEvent> & object) => void
): Promise<number | null> {
): 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<void> {
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<void> {
if (ms <= 0) return;
const signal = this.abortController.signal;
if (signal.aborted) return;
await new Promise<void>((resolve) => {
let timer: ReturnType<typeof setTimeout> | 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;
+36
View File
@@ -219,6 +219,42 @@ export class MessageQueue2<T> {
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.