fix(cli): don't lose the queued message when a remote launch fails (#1058)

* fix(cli): cap and back off consecutive remote launch failures

claudeRemoteLauncher's respawn loop retried claudeRemote() immediately
on every throw with no backoff or limit. A deterministic launch
failure (bad auth, invalid model/args, spawn failure) respawned in a
tight loop instead of giving up, hammering the same failure forever.

Track whether onReady() fired at least once per attempt to tell an
immediate/deterministic failure apart from a failure after real
progress, back off between immediate-failure retries, and after 3
consecutive immediate failures drop the message that keeps triggering
them and reset the streak, instead of respawning forever. The session
keeps running so a later, unrelated message still gets its own budget.
The streak reset on a non-throwing attempt is itself gated on having
reached onReady, not applied unconditionally -- otherwise a message
that keeps getting parked and re-picked-up on alternating attempts
(e.g. an isolated command hitting the same deterministic failure)
would reset the streak every other attempt and the cap would never
fire.

* fix(cli): restore queued message when remote launch fails before delivery

MessageQueue2.collectBatch() acks a message (fires onBatchConsumed,
which the hub uses to mark it consumed) at dequeue time, before the
message ever reaches the SDK. If claudeRemote() then throws before
onReady -- e.g. the process dies right after picking up the message --
the catch block only logged and retried, so the message vanished: the
hub already thinks it was delivered, but the CLI never acted on it.

Track the message returned from nextMessage() (whether freshly
dequeued or held in `pending` across a mode change) as in-flight until
the next onReady confirms it was handled, and restore it to the front
of the queue (preserving isolation via unshiftIsolated when needed) if
the attempt throws and will be retried. Restoring happens even if the
throw races with a user-initiated switch/exit, so a message is not
silently dropped by that unrelated shutdown either.

When the immediate-failure cap from the previous commit is reached,
the in-flight message is dropped instead of restored: unshifting it
back would just feed it into another immediate failure on the very
next attempt, storming again. This mirrors
cursorLegacyRemoteLauncher's existing drop-and-reset policy on its own
consecutive-failure cap.

* fix(cli): preserve localId when restoring a failed message batch

MessageQueue2.collectBatch() already collects each queue item's
localId (it fires onBatchConsumed with the full list to ack them), but
only exposed the joined `message` string to callers, discarding the
per-item localIds and their original boundaries in the process.

When claudeRemoteLauncher restores a dequeued-but-undelivered batch
after a launch failure, it re-added the joined string as a single new
queue item with no localId, orphaning the retried prompt from the hub
row(s) it originated from (and from cancel-by-localId).

Expose the pre-join `items` breakdown (message + localId per item)
alongside the existing joined `message` field on
collectBatch()/waitForMessagesAndGetAsString() -- purely additive, so
the other callers of waitForMessagesAndGetAsString() (grok, kimi,
opencode, cursor, codex, runAgentSession) are unaffected. On restore,
unshift each original item individually in reverse order, so the
localId and relative order of a multi-message batch are both
preserved instead of just the first item's.

* fix(cli): reset immediate-failure streak on a delivered non-onReady success

claudeRemote.ts's /clear handling delivers the queued message to the
SDK, then calls onSessionReset()/onCompletionEvent() and returns
successfully without ever calling onReady(). The success-path streak
reset only cleared on reachedReadyThisAttempt, so a successful /clear
between two unrelated immediate launch failures did not reset the
streak: an unrelated message's very next failure could hit the
3-in-a-row cap after just 1 failure, and the resulting banner would
misreport "3 times in a row".

Track whether nextMessage() actually handed a message to the SDK this
attempt (deliveredMessageThisAttempt), separately from whether the
attempt reached onReady, and reset the streak on either signal. The
livelock-prone case this guards against (a message parked into
`pending` and the attempt returning without ever delivering anything)
leaves both flags false, so it still does not reset the streak.
This commit is contained in:
Junmo Kim
2026-07-19 14:16:40 +08:00
committed by GitHub
parent bfd8c7e3fd
commit 223be6d60f
3 changed files with 608 additions and 5 deletions
+11 -3
View File
@@ -314,7 +314,7 @@ export class MessageQueue2<T> {
* Wait for messages and return all messages with the same mode as a single string
* Returns { message: string, mode: T } or null if aborted/closed
*/
async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string } | null> {
async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string, items: Array<{ message: string, localId?: string }> } | null> {
// If we have messages, return them immediately
if (this.queue.length > 0) {
return this.collectBatch();
@@ -338,7 +338,7 @@ export class MessageQueue2<T> {
/**
* Collect a batch of messages with the same mode, respecting isolation requirements
*/
private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean } | null {
private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean, items: Array<{ message: string, localId?: string }> } | null {
if (this.queue.length === 0) {
return null;
}
@@ -346,6 +346,11 @@ export class MessageQueue2<T> {
const firstItem = this.queue[0];
const sameModeMessages: string[] = [];
const consumedLocalIds: string[] = [];
// Per-item breakdown of this batch, preserved alongside the joined
// `message` string below so callers that need to requeue individual
// messages (e.g. restoring a failed batch with each item's own
// localId intact) don't have to re-split an already-joined string.
const items: Array<{ message: string, localId?: string }> = [];
let mode = firstItem.mode;
let isolate = firstItem.isolate ?? false;
const targetModeHash = firstItem.modeHash;
@@ -354,6 +359,7 @@ export class MessageQueue2<T> {
if (firstItem.isolate) {
const item = this.queue.shift()!;
sameModeMessages.push(item.message);
items.push({ message: item.message, localId: item.localId });
if (item.localId) consumedLocalIds.push(item.localId);
logger.debug(`[MessageQueue2] Collected isolated message with mode hash: ${targetModeHash}`);
} else {
@@ -363,6 +369,7 @@ export class MessageQueue2<T> {
!this.queue[0].isolate) {
const item = this.queue.shift()!;
sameModeMessages.push(item.message);
items.push({ message: item.message, localId: item.localId });
if (item.localId) consumedLocalIds.push(item.localId);
}
logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`);
@@ -379,7 +386,8 @@ export class MessageQueue2<T> {
message: combinedMessage,
mode,
hash: targetModeHash,
isolate
isolate,
items
};
}