Skip to content

Commit b544b4f

Browse files
committed
🤖 fix: make compaction crash-safe and recover via resume manager
1 parent 18a7d72 commit b544b4f

File tree

10 files changed

+338
-114
lines changed

10 files changed

+338
-114
lines changed

src/browser/hooks/useResumeManager.ts

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { useEffect, useRef } from "react";
22
import { useWorkspaceStoreRaw, type WorkspaceState } from "@/browser/stores/WorkspaceStore";
33
import { CUSTOM_EVENTS, type CustomEventType } from "@/common/constants/events";
4-
import { getAutoRetryKey, getRetryStateKey } from "@/common/constants/storage";
4+
import {
5+
getAutoRetryKey,
6+
getRetryStateKey,
7+
getCancelledCompactionKey,
8+
} from "@/common/constants/storage";
59
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
610
import { readPersistedState, updatePersistedState } from "./usePersistedState";
711
import {
812
isEligibleForAutoRetry,
913
isNonRetryableSendError,
1014
} from "@/browser/utils/messages/retryEligibility";
11-
import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions";
15+
import { executeCompaction } from "@/browser/utils/chatCommands";
1216
import type { SendMessageError } from "@/common/types/errors";
1317
import {
1418
createFailedRetryState,
@@ -23,6 +27,15 @@ export interface RetryState {
2327
lastError?: SendMessageError;
2428
}
2529

30+
/**
31+
* Persisted marker for user-cancelled compaction.
32+
* Used to distinguish intentional cancellation (Ctrl+C) from crash/force-exit.
33+
*/
34+
export interface CancelledCompactionMarker {
35+
messageId: string;
36+
timestamp: number;
37+
}
38+
2639
/**
2740
* Centralized auto-resume manager for interrupted streams
2841
*
@@ -163,45 +176,80 @@ export function useResumeManager() {
163176
);
164177

165178
try {
179+
if (!api) {
180+
retryingRef.current.delete(workspaceId);
181+
return;
182+
}
183+
166184
// Start with workspace defaults
167-
let options = getSendOptionsFromStorage(workspaceId);
185+
const options = getSendOptionsFromStorage(workspaceId);
168186

169187
// Check if last user message was a compaction request
170188
const state = workspaceStatesRef.current.get(workspaceId);
171-
if (state) {
172-
const lastUserMsg = [...state.messages].reverse().find((msg) => msg.type === "user");
173-
if (lastUserMsg?.compactionRequest) {
174-
// Apply compaction overrides using shared function (same as ChatInput)
175-
// This ensures custom model/tokens are preserved across resume
176-
options = applyCompactionOverrides(options, {
177-
model: lastUserMsg.compactionRequest.parsed.model,
178-
maxOutputTokens: lastUserMsg.compactionRequest.parsed.maxOutputTokens,
179-
continueMessage: {
180-
text: lastUserMsg.compactionRequest.parsed.continueMessage?.text ?? "",
181-
imageParts: lastUserMsg.compactionRequest.parsed.continueMessage?.imageParts,
182-
model: lastUserMsg.compactionRequest.parsed.continueMessage?.model ?? options.model,
183-
mode: lastUserMsg.compactionRequest.parsed.continueMessage?.mode ?? "exec",
184-
},
185-
});
189+
const lastUserMsg = state?.messages
190+
? [...state.messages].reverse().find((msg) => msg.type === "user")
191+
: undefined;
192+
193+
if (lastUserMsg?.compactionRequest) {
194+
// Check if this compaction was user-cancelled (Ctrl+C)
195+
const cancelledMarker = readPersistedState<CancelledCompactionMarker | null>(
196+
getCancelledCompactionKey(workspaceId),
197+
null
198+
);
199+
200+
if (cancelledMarker && cancelledMarker.messageId === lastUserMsg.id) {
201+
if (!isManual) {
202+
// User explicitly cancelled this compaction - don't auto-retry
203+
console.debug(
204+
`[retry] ${workspaceId} skipping cancelled compaction (messageId=${lastUserMsg.id})`
205+
);
206+
return;
207+
}
208+
209+
// Manual retry: clear the marker and proceed
210+
updatePersistedState(getCancelledCompactionKey(workspaceId), () => null);
186211
}
187-
}
188212

189-
if (!api) {
190-
retryingRef.current.delete(workspaceId);
191-
return;
192-
}
193-
const result = await api.workspace.resumeStream({ workspaceId, options });
213+
// Retry compaction via executeCompaction (re-sends the compaction request)
214+
// This properly rebuilds the compaction-specific behavior including continueMessage queuing
215+
console.debug(`[retry] ${workspaceId} retrying interrupted compaction`);
216+
const { parsed } = lastUserMsg.compactionRequest;
217+
const result = await executeCompaction({
218+
api,
219+
workspaceId,
220+
sendMessageOptions: options,
221+
model: parsed.model,
222+
maxOutputTokens: parsed.maxOutputTokens,
223+
continueMessage: parsed.continueMessage,
224+
editMessageId: lastUserMsg.id, // Edit the existing compaction request message
225+
});
194226

195-
if (!result.success) {
196-
// Store error in retry state so RetryBarrier can display it
197-
const newState = createFailedRetryState(attempt, result.error);
198-
console.debug(
199-
`[retry] ${workspaceId} resumeStream failed: attempt ${attempt}${newState.attempt}`
200-
);
201-
updatePersistedState(getRetryStateKey(workspaceId), newState);
227+
if (!result.success) {
228+
const errorData: SendMessageError = {
229+
type: "unknown",
230+
raw: result.error ?? "Failed to retry compaction",
231+
};
232+
const newState = createFailedRetryState(attempt, errorData);
233+
console.debug(
234+
`[retry] ${workspaceId} compaction failed: attempt ${attempt}${newState.attempt}`
235+
);
236+
updatePersistedState(getRetryStateKey(workspaceId), newState);
237+
}
238+
} else {
239+
// Normal stream resume (non-compaction)
240+
const result = await api.workspace.resumeStream({ workspaceId, options });
241+
242+
if (!result.success) {
243+
// Store error in retry state so RetryBarrier can display it
244+
const newState = createFailedRetryState(attempt, result.error);
245+
console.debug(
246+
`[retry] ${workspaceId} resumeStream failed: attempt ${attempt}${newState.attempt}`
247+
);
248+
updatePersistedState(getRetryStateKey(workspaceId), newState);
249+
}
202250
}
203251
// Note: Don't clear retry state on success - stream-end event will handle that
204-
// resumeStream success just means "stream initiated", not "stream completed"
252+
// resumeStream/executeCompaction success just means "stream initiated", not "stream completed"
205253
// Clearing here causes backoff reset bug when stream starts then immediately fails
206254
} catch (error) {
207255
// Store error in retry state for display

src/browser/utils/chatCommands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
resolveCompactionModel,
2727
isValidModelFormat,
2828
} from "@/browser/utils/messages/compactionModelPreference";
29+
import { getCancelledCompactionKey } from "@/common/constants/storage";
30+
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
2931
import type { ImageAttachment } from "../components/ImageAttachments";
3032
import { dispatchWorkspaceSwitch } from "./workspaceEvents";
3133
import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage";
@@ -695,6 +697,10 @@ export function prepareCompactionMessage(options: CompactionOptions): {
695697
export async function executeCompaction(
696698
options: CompactionOptions & { api: RouterClient<AppRouter> }
697699
): Promise<CompactionResult> {
700+
// Clear any cancelled-compaction marker since we're (re-)starting compaction
701+
// This allows auto-retry to work if this attempt is interrupted by crash/force-exit
702+
updatePersistedState(getCancelledCompactionKey(options.workspaceId), () => null);
703+
698704
const { messageText, metadata, sendOptions } = prepareCompactionMessage(options);
699705

700706
const result = await options.api.workspace.sendMessage({

src/browser/utils/compaction/handler.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
* with original /compact command restored for re-editing.
66
*/
77

8-
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
98
import type { APIClient } from "@/browser/contexts/API";
9+
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
10+
import type { CancelledCompactionMarker } from "@/browser/hooks/useResumeManager";
11+
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
12+
import { getCancelledCompactionKey } from "@/common/constants/storage";
13+
1014
import { buildCompactionEditText } from "./format";
1115

1216
/**
@@ -81,6 +85,14 @@ export async function cancelCompaction(
8185
// clobber the edit buffer.
8286
startEditingMessage(compactionRequestMsg.id, command);
8387

88+
// Mark this compaction as user-cancelled so auto-retry doesn't pick it up.
89+
// This distinguishes intentional Ctrl+C from crash/force-exit.
90+
const marker: CancelledCompactionMarker = {
91+
messageId: compactionRequestMsg.id,
92+
timestamp: Date.now(),
93+
};
94+
updatePersistedState(getCancelledCompactionKey(workspaceId), () => marker);
95+
8496
// Interrupt stream with abandonPartial flag
8597
// Backend detects this and skips compaction (Ctrl+C flow)
8698
await client.workspace.interruptStream({

src/common/orpc/schemas/stream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [
322322
UsageDeltaEventSchema,
323323
QueuedMessageChangedEventSchema,
324324
RestoreToInputEventSchema,
325-
// Idle compaction notification
325+
// Compaction notifications
326326
IdleCompactionNeededEventSchema,
327327
// Init events
328328
...WorkspaceInitEventSchema.def.options,

src/node/services/agentSession.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,8 @@ export class AgentSession {
299299
workspaceId: this.workspaceId,
300300
message: { type: "caught-up" },
301301
});
302+
// Note: Aborted compaction recovery is handled by useResumeManager on the frontend,
303+
// which detects interrupted compaction-request messages and retries via executeCompaction.
302304
}
303305
}
304306

0 commit comments

Comments
 (0)