Skip to content

Commit b761b11

Browse files
authored
🤖 fix: ensure caught-up is always sent during workspace loading (#1236)
Prevent workspaces from getting stuck in 'Loading workspace...' state when errors occur during history replay. ## Root cause `emitHistoricalEvents` could fail before sending `caught-up`. If `readPartial`, `replayStream`, or `replayInit` threw an exception, the `caught-up` message would never be sent, leaving the frontend stuck in the loading state indefinitely with no indication of what went wrong. ## Fix Wrap replay logic in `try/finally` - the `finally` block guarantees `caught-up` is always sent regardless of what happens during replay. This is clear by construction rather than relying on error handling. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 706b284 commit b761b11

File tree

1 file changed

+48
-38
lines changed

1 file changed

+48
-38
lines changed

src/node/services/agentSession.ts

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { EventEmitter } from "events";
33
import * as path from "path";
44
import { stat, readFile } from "fs/promises";
55
import { PlatformPaths } from "@/common/utils/paths";
6+
import { log } from "@/node/services/log";
67
import { createMuxMessage } from "@/common/types/message";
78
import type { Config } from "@/node/config";
89
import type { AIService } from "@/node/services/aiService";
@@ -247,48 +248,57 @@ export class AgentSession {
247248
private async emitHistoricalEvents(
248249
listener: (event: AgentSessionChatEvent) => void
249250
): Promise<void> {
250-
// Read partial BEFORE iterating history so we can skip the corresponding
251-
// placeholder message (which has empty parts). The partial has the real content.
252-
const streamInfo = this.aiService.getStreamInfo(this.workspaceId);
253-
const partial = await this.partialService.readPartial(this.workspaceId);
254-
const partialHistorySequence = partial?.metadata?.historySequence;
255-
256-
// Load chat history (persisted messages from chat.jsonl)
257-
const historyResult = await this.historyService.getHistory(this.workspaceId);
258-
if (historyResult.success) {
259-
for (const message of historyResult.data) {
260-
// Skip the placeholder message if we have a partial with the same historySequence.
261-
// The placeholder has empty parts; the partial has the actual content.
262-
// Without this, both get loaded and the empty placeholder may be shown as "last message".
263-
if (
264-
partialHistorySequence !== undefined &&
265-
message.metadata?.historySequence === partialHistorySequence
266-
) {
267-
continue;
251+
// try/catch/finally guarantees caught-up is always sent, even if replay fails.
252+
// Without caught-up, the frontend stays in "Loading workspace..." forever.
253+
try {
254+
// Read partial BEFORE iterating history so we can skip the corresponding
255+
// placeholder message (which has empty parts). The partial has the real content.
256+
const streamInfo = this.aiService.getStreamInfo(this.workspaceId);
257+
const partial = await this.partialService.readPartial(this.workspaceId);
258+
const partialHistorySequence = partial?.metadata?.historySequence;
259+
260+
// Load chat history (persisted messages from chat.jsonl)
261+
const historyResult = await this.historyService.getHistory(this.workspaceId);
262+
if (historyResult.success) {
263+
for (const message of historyResult.data) {
264+
// Skip the placeholder message if we have a partial with the same historySequence.
265+
// The placeholder has empty parts; the partial has the actual content.
266+
// Without this, both get loaded and the empty placeholder may be shown as "last message".
267+
if (
268+
partialHistorySequence !== undefined &&
269+
message.metadata?.historySequence === partialHistorySequence
270+
) {
271+
continue;
272+
}
273+
// Add type: "message" for discriminated union (messages from chat.jsonl don't have it)
274+
listener({ workspaceId: this.workspaceId, message: { ...message, type: "message" } });
268275
}
269-
// Add type: "message" for discriminated union (messages from chat.jsonl don't have it)
270-
listener({ workspaceId: this.workspaceId, message: { ...message, type: "message" } });
271276
}
272-
}
273-
274-
if (streamInfo) {
275-
await this.aiService.replayStream(this.workspaceId);
276-
} else if (partial) {
277-
// Add type: "message" for discriminated union (partials from disk don't have it)
278-
listener({ workspaceId: this.workspaceId, message: { ...partial, type: "message" } });
279-
}
280277

281-
// Replay init state BEFORE caught-up (treat as historical data)
282-
// This ensures init events are buffered correctly by the frontend,
283-
// preserving their natural timing characteristics from the hook execution.
284-
await this.initStateManager.replayInit(this.workspaceId);
278+
if (streamInfo) {
279+
await this.aiService.replayStream(this.workspaceId);
280+
} else if (partial) {
281+
// Add type: "message" for discriminated union (partials from disk don't have it)
282+
listener({ workspaceId: this.workspaceId, message: { ...partial, type: "message" } });
283+
}
285284

286-
// Send caught-up after ALL historical data (including init events)
287-
// This signals frontend that replay is complete and future events are real-time
288-
listener({
289-
workspaceId: this.workspaceId,
290-
message: { type: "caught-up" },
291-
});
285+
// Replay init state BEFORE caught-up (treat as historical data)
286+
// This ensures init events are buffered correctly by the frontend,
287+
// preserving their natural timing characteristics from the hook execution.
288+
await this.initStateManager.replayInit(this.workspaceId);
289+
} catch (error) {
290+
log.error("Failed to replay history for workspace", {
291+
workspaceId: this.workspaceId,
292+
error,
293+
});
294+
} finally {
295+
// Send caught-up after ALL historical data (including init events)
296+
// This signals frontend that replay is complete and future events are real-time
297+
listener({
298+
workspaceId: this.workspaceId,
299+
message: { type: "caught-up" },
300+
});
301+
}
292302
}
293303

294304
async ensureMetadata(args: {

0 commit comments

Comments
 (0)