Skip to content

Commit 2488cc6

Browse files
committed
🤖 fix: auto-retry aborted compaction on workspace reload
When compaction is interrupted (app restart, crash, network loss), the compaction-request user message is left in history without a completed summary message. Previously, users would need to manually retry. This change: - Adds detectAbortedCompaction() to identify when a compaction was interrupted by checking if the last user message is a compaction-request without a following compacted assistant message - Emits a new 'aborted-compaction' event during history replay when an aborted compaction is detected - Adds useAbortedCompactionRetry hook to automatically retry the compaction using the original request parameters (model, maxOutputTokens, continueMessage, source) - Uses the same pattern as useIdleCompactionHandler for consistency This provides a strong guarantee that compaction will complete: 1. Original compaction-request stays in history until compaction succeeds 2. On reload, aborted compaction is detected and automatically retried 3. Retry uses the same parameters from the original /compact command --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent cb30e65 commit 2488cc6

File tree

7 files changed

+400
-1
lines changed

7 files changed

+400
-1
lines changed

src/browser/components/AIView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
6767
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
6868
import { useForceCompaction } from "@/browser/hooks/useForceCompaction";
6969
import { useIdleCompactionHandler } from "@/browser/hooks/useIdleCompactionHandler";
70+
import { useAbortedCompactionRetry } from "@/browser/hooks/useAbortedCompactionRetry";
7071
import { useAPI } from "@/browser/contexts/API";
7172
import { useReviews } from "@/browser/hooks/useReviews";
7273
import { ReviewsBanner } from "./ReviewsBanner";
@@ -244,6 +245,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
244245
// Idle compaction - trigger compaction when backend signals workspace has been idle
245246
useIdleCompactionHandler({ api });
246247

248+
// Aborted compaction retry - automatically retry compaction interrupted by restart/crash
249+
useAbortedCompactionRetry({ api });
250+
247251
// Auto-retry state - minimal setter for keybinds and message sent handler
248252
// RetryBarrier manages its own state, but we need this for interrupt keybind
249253
const [, setAutoRetry] = usePersistedState<boolean>(
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Hook to handle aborted compaction retry.
3+
*
4+
* When a compaction is interrupted (app restart, crash, network loss), the backend
5+
* detects this on history replay and emits an `aborted-compaction` event.
6+
*
7+
* This hook listens for these signals and automatically retries the compaction
8+
* using the stored parameters from the original request.
9+
*
10+
* The retry uses the same compaction options (model, maxOutputTokens, etc.) that
11+
* were specified in the original /compact command. The compaction-request user
12+
* message remains in history as the trigger for the retry.
13+
*/
14+
15+
import { useEffect, useRef } from "react";
16+
import type { RouterClient } from "@orpc/server";
17+
import type { AppRouter } from "@/node/orpc/router";
18+
import { executeCompaction } from "@/browser/utils/chatCommands";
19+
import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
20+
import { workspaceStore } from "@/browser/stores/WorkspaceStore";
21+
import type { AbortedCompactionEventSchema } from "@/common/orpc/schemas/stream";
22+
import type { z } from "zod";
23+
24+
type CompactionMetadata = z.infer<typeof AbortedCompactionEventSchema>["compactionMetadata"];
25+
26+
export interface AbortedCompactionRetryParams {
27+
api: RouterClient<AppRouter> | null;
28+
}
29+
30+
/**
31+
* Hook to automatically retry compaction when the backend detects an aborted compaction.
32+
* Should be called at a high level (e.g., App or AIView) to handle all workspaces.
33+
*/
34+
export function useAbortedCompactionRetry(params: AbortedCompactionRetryParams): void {
35+
const { api } = params;
36+
37+
// Track which workspaces we've triggered retry for (to prevent duplicates)
38+
const retriedWorkspacesRef = useRef(new Set<string>());
39+
40+
useEffect(() => {
41+
if (!api) return;
42+
43+
const handleAbortedCompaction = (workspaceId: string, metadata: CompactionMetadata) => {
44+
// Skip if already retrying for this workspace
45+
if (retriedWorkspacesRef.current.has(workspaceId)) {
46+
return;
47+
}
48+
49+
retriedWorkspacesRef.current.add(workspaceId);
50+
51+
console.info(`[compaction] Retrying aborted compaction for workspace ${workspaceId}`);
52+
53+
// Build send options using current workspace settings
54+
const baseSendMessageOptions = buildSendMessageOptions(workspaceId);
55+
56+
// Apply any model override from the original compaction request
57+
const sendMessageOptions = {
58+
...baseSendMessageOptions,
59+
// If the original request had a custom model, use it
60+
// Otherwise use the workspace default
61+
...(metadata.parsed.model && { model: metadata.parsed.model }),
62+
};
63+
64+
// Determine the source based on original request
65+
const source = metadata.source === "idle-compaction" ? "idle-compaction" : undefined;
66+
67+
// Convert continueMessage from schema shape to ContinueMessage type
68+
// Schema has optional text, but ContinueMessage requires it (defaulting to empty string)
69+
const continueMessage = metadata.parsed.continueMessage
70+
? {
71+
text: metadata.parsed.continueMessage.text ?? "",
72+
imageParts: metadata.parsed.continueMessage.imageParts,
73+
model: metadata.parsed.continueMessage.model,
74+
}
75+
: undefined;
76+
77+
void executeCompaction({
78+
api,
79+
workspaceId,
80+
sendMessageOptions,
81+
model: metadata.parsed.model,
82+
maxOutputTokens: metadata.parsed.maxOutputTokens,
83+
continueMessage,
84+
source,
85+
}).then((result) => {
86+
if (!result.success) {
87+
console.error("Aborted compaction retry failed:", result.error);
88+
} else {
89+
console.info(`[compaction] Successfully retried compaction for workspace ${workspaceId}`);
90+
}
91+
// Clear from retried set after completion
92+
retriedWorkspacesRef.current.delete(workspaceId);
93+
});
94+
};
95+
96+
const unsubscribe = workspaceStore.onAbortedCompaction(handleAbortedCompaction);
97+
return unsubscribe;
98+
}, [api]);
99+
}

src/browser/stores/WorkspaceStore.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import {
1919
isMuxMessage,
2020
isQueuedMessageChanged,
2121
isRestoreToInput,
22+
isAbortedCompaction,
2223
} from "@/common/orpc/types";
24+
import type { AbortedCompactionEventSchema } from "@/common/orpc/schemas/stream";
25+
26+
type AbortedCompactionEvent = z.infer<typeof AbortedCompactionEventSchema>;
2327
import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream";
2428
import { MapStore } from "./MapStore";
2529
import { createDisplayUsage } from "@/common/utils/tokens/displayUsage";
@@ -301,6 +305,11 @@ export class WorkspaceStore {
301305
// Idle compaction notification callbacks (called when backend signals idle compaction needed)
302306
private idleCompactionCallbacks = new Set<(workspaceId: string) => void>();
303307

308+
// Aborted compaction notification callbacks (called when backend detects an aborted compaction on replay)
309+
private abortedCompactionCallbacks = new Set<
310+
(workspaceId: string, metadata: AbortedCompactionEvent["compactionMetadata"]) => void
311+
>();
312+
304313
// Tracks when a file-modifying tool (file_edit_*, bash) last completed per workspace.
305314
// ReviewPanel subscribes to trigger diff refresh. Two structures:
306315
// - timestamps: actual Date.now() values for cache invalidation checks
@@ -1449,6 +1458,34 @@ export class WorkspaceStore {
14491458
}
14501459
}
14511460

1461+
/**
1462+
* Subscribe to aborted compaction events.
1463+
* Callback is called when backend detects an aborted compaction that needs retry.
1464+
* Returns unsubscribe function.
1465+
*/
1466+
onAbortedCompaction(
1467+
callback: (workspaceId: string, metadata: AbortedCompactionEvent["compactionMetadata"]) => void
1468+
): () => void {
1469+
this.abortedCompactionCallbacks.add(callback);
1470+
return () => this.abortedCompactionCallbacks.delete(callback);
1471+
}
1472+
1473+
/**
1474+
* Notify all listeners that an aborted compaction was detected.
1475+
*/
1476+
private notifyAbortedCompaction(
1477+
workspaceId: string,
1478+
metadata: AbortedCompactionEvent["compactionMetadata"]
1479+
): void {
1480+
for (const callback of this.abortedCompactionCallbacks) {
1481+
try {
1482+
callback(workspaceId, metadata);
1483+
} catch (error) {
1484+
console.error("Error in aborted compaction callback:", error);
1485+
}
1486+
}
1487+
}
1488+
14521489
/**
14531490
* Subscribe to file-modifying tool completions for a workspace.
14541491
* Used by ReviewPanel to trigger diff refresh.
@@ -1556,6 +1593,12 @@ export class WorkspaceStore {
15561593
return;
15571594
}
15581595

1596+
// Handle aborted-compaction event (detected on history replay)
1597+
if (isAbortedCompaction(data)) {
1598+
this.notifyAbortedCompaction(workspaceId, data.compactionMetadata);
1599+
return;
1600+
}
1601+
15591602
// OPTIMIZATION: Buffer stream events until caught-up to reduce excess re-renders
15601603
// When first subscribing to a workspace, we receive:
15611604
// 1. Historical messages from chat.jsonl (potentially hundreds of messages)
@@ -1700,6 +1743,9 @@ function getStoreInstance(): WorkspaceStore {
17001743
export const workspaceStore = {
17011744
onIdleCompactionNeeded: (callback: (workspaceId: string) => void) =>
17021745
getStoreInstance().onIdleCompactionNeeded(callback),
1746+
onAbortedCompaction: (
1747+
callback: (workspaceId: string, metadata: AbortedCompactionEvent["compactionMetadata"]) => void
1748+
) => getStoreInstance().onAbortedCompaction(callback),
17031749
subscribeFileModifyingTool: (workspaceId: string, listener: () => void) =>
17041750
getStoreInstance().subscribeFileModifyingTool(workspaceId, listener),
17051751
getFileModifyingToolMs: (workspaceId: string) =>

src/common/orpc/schemas/stream.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,28 @@ export const IdleCompactionNeededEventSchema = z.object({
2020
type: z.literal("idle-compaction-needed"),
2121
});
2222

23+
/** Sent when an aborted compaction is detected on history replay */
24+
export const AbortedCompactionEventSchema = z.object({
25+
type: z.literal("aborted-compaction"),
26+
messageId: z.string(),
27+
compactionMetadata: z.object({
28+
type: z.literal("compaction-request"),
29+
rawCommand: z.string(),
30+
parsed: z.object({
31+
model: z.string().optional(),
32+
maxOutputTokens: z.number().optional(),
33+
continueMessage: z
34+
.object({
35+
text: z.string().optional(),
36+
imageParts: z.array(ImagePartSchema).optional(),
37+
model: z.string().optional(),
38+
})
39+
.optional(),
40+
}),
41+
source: z.enum(["idle-compaction"]).optional(),
42+
}),
43+
});
44+
2345
export const StreamErrorMessageSchema = z.object({
2446
type: z.literal("stream-error"),
2547
messageId: z.string(),
@@ -318,8 +340,9 @@ export const WorkspaceChatMessageSchema = z.discriminatedUnion("type", [
318340
UsageDeltaEventSchema,
319341
QueuedMessageChangedEventSchema,
320342
RestoreToInputEventSchema,
321-
// Idle compaction notification
343+
// Compaction notifications
322344
IdleCompactionNeededEventSchema,
345+
AbortedCompactionEventSchema,
323346
// Init events
324347
...WorkspaceInitEventSchema.def.options,
325348
// Chat messages with type discriminator

src/common/orpc/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,9 @@ export function isRestoreToInput(
128128
): msg is Extract<WorkspaceChatMessage, { type: "restore-to-input" }> {
129129
return (msg as { type?: string }).type === "restore-to-input";
130130
}
131+
132+
export function isAbortedCompaction(
133+
msg: WorkspaceChatMessage
134+
): msg is Extract<WorkspaceChatMessage, { type: "aborted-compaction" }> {
135+
return (msg as { type?: string }).type === "aborted-compaction";
136+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { detectAbortedCompaction } from "./agentSession";
3+
import type { MuxMessage, MuxFrontendMetadata } from "@/common/types/message";
4+
5+
describe("detectAbortedCompaction", () => {
6+
const createUserMessage = (id: string, muxMetadata?: MuxFrontendMetadata): MuxMessage => ({
7+
id,
8+
role: "user",
9+
parts: [{ type: "text", text: "test message" }],
10+
metadata: {
11+
historySequence: 1,
12+
timestamp: Date.now(),
13+
...(muxMetadata && { muxMetadata }),
14+
},
15+
});
16+
17+
const createAssistantMessage = (
18+
id: string,
19+
compacted?: boolean | "user" | "idle"
20+
): MuxMessage => ({
21+
id,
22+
role: "assistant",
23+
parts: [{ type: "text", text: "assistant response" }],
24+
metadata: {
25+
historySequence: 2,
26+
timestamp: Date.now(),
27+
...(compacted !== undefined && { compacted }),
28+
},
29+
});
30+
31+
test("returns null for empty history", () => {
32+
expect(detectAbortedCompaction([])).toBeNull();
33+
});
34+
35+
test("returns null when no user messages exist", () => {
36+
const messages: MuxMessage[] = [createAssistantMessage("a1")];
37+
expect(detectAbortedCompaction(messages)).toBeNull();
38+
});
39+
40+
test("returns null for regular user message (not compaction)", () => {
41+
const messages: MuxMessage[] = [createUserMessage("u1"), createAssistantMessage("a1")];
42+
expect(detectAbortedCompaction(messages)).toBeNull();
43+
});
44+
45+
test("detects aborted compaction when no assistant response follows", () => {
46+
const compactionRequest = createUserMessage("u1", {
47+
type: "compaction-request",
48+
rawCommand: "/compact",
49+
parsed: {},
50+
});
51+
const messages: MuxMessage[] = [compactionRequest];
52+
53+
const result = detectAbortedCompaction(messages);
54+
expect(result).not.toBeNull();
55+
expect(result?.id).toBe("u1");
56+
});
57+
58+
test("detects aborted compaction when assistant response lacks compacted flag", () => {
59+
const compactionRequest = createUserMessage("u1", {
60+
type: "compaction-request",
61+
rawCommand: "/compact",
62+
parsed: {},
63+
});
64+
const messages: MuxMessage[] = [
65+
compactionRequest,
66+
createAssistantMessage("a1"), // No compacted flag - incomplete
67+
];
68+
69+
const result = detectAbortedCompaction(messages);
70+
expect(result).not.toBeNull();
71+
expect(result?.id).toBe("u1");
72+
});
73+
74+
test("returns null when compaction completed (compacted: true)", () => {
75+
const compactionRequest = createUserMessage("u1", {
76+
type: "compaction-request",
77+
rawCommand: "/compact",
78+
parsed: {},
79+
});
80+
const messages: MuxMessage[] = [compactionRequest, createAssistantMessage("a1", true)];
81+
82+
expect(detectAbortedCompaction(messages)).toBeNull();
83+
});
84+
85+
test("returns null when compaction completed (compacted: 'user')", () => {
86+
const compactionRequest = createUserMessage("u1", {
87+
type: "compaction-request",
88+
rawCommand: "/compact",
89+
parsed: {},
90+
});
91+
const messages: MuxMessage[] = [compactionRequest, createAssistantMessage("a1", "user")];
92+
93+
expect(detectAbortedCompaction(messages)).toBeNull();
94+
});
95+
96+
test("returns null when compaction completed (compacted: 'idle')", () => {
97+
const compactionRequest = createUserMessage("u1", {
98+
type: "compaction-request",
99+
rawCommand: "/compact --idle",
100+
parsed: {},
101+
});
102+
const messages: MuxMessage[] = [compactionRequest, createAssistantMessage("a1", "idle")];
103+
104+
expect(detectAbortedCompaction(messages)).toBeNull();
105+
});
106+
107+
test("only looks at last user message", () => {
108+
const oldCompactionRequest = createUserMessage("u1", {
109+
type: "compaction-request",
110+
rawCommand: "/compact",
111+
parsed: {},
112+
});
113+
const normalUserMessage = createUserMessage("u2");
114+
115+
const messages: MuxMessage[] = [
116+
oldCompactionRequest,
117+
createAssistantMessage("a1"),
118+
normalUserMessage,
119+
createAssistantMessage("a2"),
120+
];
121+
122+
// Last user message is normal, not a compaction request
123+
expect(detectAbortedCompaction(messages)).toBeNull();
124+
});
125+
126+
test("detects aborted compaction in longer history", () => {
127+
const normalUser1 = createUserMessage("u1");
128+
const normalUser2 = createUserMessage("u2");
129+
const compactionRequest = createUserMessage("u3", {
130+
type: "compaction-request",
131+
rawCommand: "/compact",
132+
parsed: { model: "claude-sonnet-4-20250514" },
133+
});
134+
135+
const messages: MuxMessage[] = [
136+
normalUser1,
137+
createAssistantMessage("a1"),
138+
normalUser2,
139+
createAssistantMessage("a2"),
140+
compactionRequest,
141+
// No assistant response - compaction was interrupted
142+
];
143+
144+
const result = detectAbortedCompaction(messages);
145+
expect(result).not.toBeNull();
146+
expect(result?.id).toBe("u3");
147+
expect(result?.metadata?.muxMetadata?.type).toBe("compaction-request");
148+
});
149+
});

0 commit comments

Comments
 (0)