Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 80 additions & 32 deletions src/browser/hooks/useResumeManager.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { useEffect, useRef } from "react";
import { useWorkspaceStoreRaw, type WorkspaceState } from "@/browser/stores/WorkspaceStore";
import { CUSTOM_EVENTS, type CustomEventType } from "@/common/constants/events";
import { getAutoRetryKey, getRetryStateKey } from "@/common/constants/storage";
import {
getAutoRetryKey,
getRetryStateKey,
getCancelledCompactionKey,
} from "@/common/constants/storage";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import { readPersistedState, updatePersistedState } from "./usePersistedState";
import {
isEligibleForAutoRetry,
isNonRetryableSendError,
} from "@/browser/utils/messages/retryEligibility";
import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions";
import { executeCompaction } from "@/browser/utils/chatCommands";
import type { SendMessageError } from "@/common/types/errors";
import {
createFailedRetryState,
Expand All @@ -23,6 +27,15 @@ export interface RetryState {
lastError?: SendMessageError;
}

/**
* Persisted marker for user-cancelled compaction.
* Used to distinguish intentional cancellation (Ctrl+C) from crash/force-exit.
*/
export interface CancelledCompactionMarker {
messageId: string;
timestamp: number;
}

/**
* Centralized auto-resume manager for interrupted streams
*
Expand Down Expand Up @@ -163,45 +176,80 @@ export function useResumeManager() {
);

try {
if (!api) {
retryingRef.current.delete(workspaceId);
return;
}

// Start with workspace defaults
let options = getSendOptionsFromStorage(workspaceId);
const options = getSendOptionsFromStorage(workspaceId);

// Check if last user message was a compaction request
const state = workspaceStatesRef.current.get(workspaceId);
if (state) {
const lastUserMsg = [...state.messages].reverse().find((msg) => msg.type === "user");
if (lastUserMsg?.compactionRequest) {
// Apply compaction overrides using shared function (same as ChatInput)
// This ensures custom model/tokens are preserved across resume
options = applyCompactionOverrides(options, {
model: lastUserMsg.compactionRequest.parsed.model,
maxOutputTokens: lastUserMsg.compactionRequest.parsed.maxOutputTokens,
continueMessage: {
text: lastUserMsg.compactionRequest.parsed.continueMessage?.text ?? "",
imageParts: lastUserMsg.compactionRequest.parsed.continueMessage?.imageParts,
model: lastUserMsg.compactionRequest.parsed.continueMessage?.model ?? options.model,
mode: lastUserMsg.compactionRequest.parsed.continueMessage?.mode ?? "exec",
},
});
const lastUserMsg = state?.messages
? [...state.messages].reverse().find((msg) => msg.type === "user")
: undefined;

if (lastUserMsg?.compactionRequest) {
// Check if this compaction was user-cancelled (Ctrl+C)
const cancelledMarker = readPersistedState<CancelledCompactionMarker | null>(
getCancelledCompactionKey(workspaceId),
null
);

if (cancelledMarker && cancelledMarker.messageId === lastUserMsg.id) {
if (!isManual) {
// User explicitly cancelled this compaction - don't auto-retry
console.debug(
`[retry] ${workspaceId} skipping cancelled compaction (messageId=${lastUserMsg.id})`
);
return;
}

// Manual retry: clear the marker and proceed
updatePersistedState(getCancelledCompactionKey(workspaceId), () => null);
}
}

if (!api) {
retryingRef.current.delete(workspaceId);
return;
}
const result = await api.workspace.resumeStream({ workspaceId, options });
// Retry compaction via executeCompaction (re-sends the compaction request)
// This properly rebuilds the compaction-specific behavior including continueMessage queuing
console.debug(`[retry] ${workspaceId} retrying interrupted compaction`);
const { parsed } = lastUserMsg.compactionRequest;
const result = await executeCompaction({
api,
workspaceId,
sendMessageOptions: options,
model: parsed.model,
maxOutputTokens: parsed.maxOutputTokens,
continueMessage: parsed.continueMessage,
editMessageId: lastUserMsg.id, // Edit the existing compaction request message
});

if (!result.success) {
// Store error in retry state so RetryBarrier can display it
const newState = createFailedRetryState(attempt, result.error);
console.debug(
`[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}`
);
updatePersistedState(getRetryStateKey(workspaceId), newState);
if (!result.success) {
const errorData: SendMessageError = {
type: "unknown",
raw: result.error ?? "Failed to retry compaction",
};
const newState = createFailedRetryState(attempt, errorData);
console.debug(
`[retry] ${workspaceId} compaction failed: attempt ${attempt} → ${newState.attempt}`
);
updatePersistedState(getRetryStateKey(workspaceId), newState);
}
} else {
// Normal stream resume (non-compaction)
const result = await api.workspace.resumeStream({ workspaceId, options });

if (!result.success) {
// Store error in retry state so RetryBarrier can display it
const newState = createFailedRetryState(attempt, result.error);
console.debug(
`[retry] ${workspaceId} resumeStream failed: attempt ${attempt} → ${newState.attempt}`
);
updatePersistedState(getRetryStateKey(workspaceId), newState);
}
}
// Note: Don't clear retry state on success - stream-end event will handle that
// resumeStream success just means "stream initiated", not "stream completed"
// resumeStream/executeCompaction success just means "stream initiated", not "stream completed"
// Clearing here causes backoff reset bug when stream starts then immediately fails
} catch (error) {
// Store error in retry state for display
Expand Down
6 changes: 6 additions & 0 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
resolveCompactionModel,
isValidModelFormat,
} from "@/browser/utils/messages/compactionModelPreference";
import { getCancelledCompactionKey } from "@/common/constants/storage";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import type { ImageAttachment } from "../components/ImageAttachments";
import { dispatchWorkspaceSwitch } from "./workspaceEvents";
import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage";
Expand Down Expand Up @@ -695,6 +697,10 @@ export function prepareCompactionMessage(options: CompactionOptions): {
export async function executeCompaction(
options: CompactionOptions & { api: RouterClient<AppRouter> }
): Promise<CompactionResult> {
// Clear any cancelled-compaction marker since we're (re-)starting compaction
// This allows auto-retry to work if this attempt is interrupted by crash/force-exit
updatePersistedState(getCancelledCompactionKey(options.workspaceId), () => null);

const { messageText, metadata, sendOptions } = prepareCompactionMessage(options);

const result = await options.api.workspace.sendMessage({
Expand Down
14 changes: 13 additions & 1 deletion src/browser/utils/compaction/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
* with original /compact command restored for re-editing.
*/

import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
import type { APIClient } from "@/browser/contexts/API";
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import type { CancelledCompactionMarker } from "@/browser/hooks/useResumeManager";
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
import { getCancelledCompactionKey } from "@/common/constants/storage";

import { buildCompactionEditText } from "./format";

/**
Expand Down Expand Up @@ -81,6 +85,14 @@ export async function cancelCompaction(
// clobber the edit buffer.
startEditingMessage(compactionRequestMsg.id, command);

// Mark this compaction as user-cancelled so auto-retry doesn't pick it up.
// This distinguishes intentional Ctrl+C from crash/force-exit.
const marker: CancelledCompactionMarker = {
messageId: compactionRequestMsg.id,
timestamp: Date.now(),
};
updatePersistedState(getCancelledCompactionKey(workspaceId), () => marker);

// Interrupt stream with abandonPartial flag
// Backend detects this and skips compaction (Ctrl+C flow)
await client.workspace.interruptStream({
Expand Down
2 changes: 1 addition & 1 deletion src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [
UsageDeltaEventSchema,
QueuedMessageChangedEventSchema,
RestoreToInputEventSchema,
// Idle compaction notification
// Compaction notifications
IdleCompactionNeededEventSchema,
// Init events
...WorkspaceInitEventSchema.def.options,
Expand Down
2 changes: 2 additions & 0 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ export class AgentSession {
workspaceId: this.workspaceId,
message: { type: "caught-up" },
});
// Note: Aborted compaction recovery is handled by useResumeManager on the frontend,
// which detects interrupted compaction-request messages and retries via executeCompaction.
}
}

Expand Down
Loading