Skip to content

Commit a07c86b

Browse files
authored
feat: Improve session error handling and display (#928)
- Improve session error display - Preserve conversation history during session retry - Resuming existing tasks locally from remote is broken - If there is no workspace setup for the task (e.g. they have not linked the local repo) it will now prompt them to do so to resume on their machine
1 parent 674e038 commit a07c86b

File tree

8 files changed

+262
-67
lines changed

8 files changed

+262
-67
lines changed

apps/twig/src/main/services/agent/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,7 @@ For git operations while detached:
10161016
}
10171017
this.cleanupMockNodeEnvironment(session.mockNodeDir);
10181018
this.sessions.delete(taskRunId);
1019+
this.processTracking.killByTaskId(session.taskId);
10191020
}
10201021
}
10211022

apps/twig/src/renderer/features/sessions/components/SessionView.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -381,13 +381,12 @@ export function SessionView({
381381
className="absolute inset-0 bg-gray-1"
382382
>
383383
<Warning size={32} weight="duotone" color="var(--red-9)" />
384-
<Text size="3" weight="medium" color="red">
385-
Session Error
386-
</Text>
387384
<Text
388-
size="2"
385+
size="3"
386+
weight="medium"
389387
align="center"
390-
className="max-w-md px-4 text-gray-11"
388+
color="red"
389+
className="max-w-md px-4"
391390
>
392391
{errorMessage}
393392
</Text>

apps/twig/src/renderer/features/sessions/service/service.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ vi.mock("@features/sessions/stores/modelsStore", () => ({
8181
const mockSessionConfigStore = vi.hoisted(() => ({
8282
getPersistedConfigOptions: vi.fn(() => undefined),
8383
setPersistedConfigOptions: vi.fn(),
84+
removePersistedConfigOptions: vi.fn(),
8485
updatePersistedConfigOptionValue: vi.fn(),
8586
}));
8687

@@ -89,6 +90,26 @@ vi.mock(
8990
() => mockSessionConfigStore,
9091
);
9192

93+
const mockAdapterFns = vi.hoisted(() => ({
94+
setAdapter: vi.fn(),
95+
getAdapter: vi.fn(),
96+
removeAdapter: vi.fn(),
97+
}));
98+
99+
const mockSessionAdapterStore = vi.hoisted(() => ({
100+
useSessionAdapterStore: {
101+
getState: vi.fn(() => ({
102+
adaptersByRunId: {},
103+
...mockAdapterFns,
104+
})),
105+
},
106+
}));
107+
108+
vi.mock(
109+
"@features/sessions/stores/sessionAdapterStore",
110+
() => mockSessionAdapterStore,
111+
);
112+
92113
const mockGetIsOnline = vi.hoisted(() => vi.fn(() => true));
93114

94115
vi.mock("@/renderer/stores/connectivityStore", () => ({
@@ -732,7 +753,7 @@ describe("SessionService", () => {
732753
});
733754

734755
describe("clearSessionError", () => {
735-
it("cancels agent and removes session", async () => {
756+
it("cancels agent and tears down session fully", async () => {
736757
const service = getSessionService();
737758
const mockSession = createMockSession({ status: "error" });
738759
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession);
@@ -745,6 +766,10 @@ describe("SessionService", () => {
745766
expect(mockSessionStoreSetters.removeSession).toHaveBeenCalledWith(
746767
"run-123",
747768
);
769+
expect(mockAdapterFns.removeAdapter).toHaveBeenCalledWith("run-123");
770+
expect(
771+
mockSessionConfigStore.removePersistedConfigOptions,
772+
).toHaveBeenCalledWith("run-123");
748773
});
749774

750775
it("handles missing session gracefully", async () => {

apps/twig/src/renderer/features/sessions/service/service.ts

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useModelsStore } from "@features/sessions/stores/modelsStore";
1111
import { useSessionAdapterStore } from "@features/sessions/stores/sessionAdapterStore";
1212
import {
1313
getPersistedConfigOptions,
14+
removePersistedConfigOptions,
1415
setPersistedConfigOptions,
1516
updatePersistedConfigOptionValue,
1617
} from "@features/sessions/stores/sessionConfigStore";
@@ -361,26 +362,98 @@ export class SessionService {
361362
}
362363
}
363364
} else {
364-
this.unsubscribeFromChannel(taskRunId);
365-
sessionStoreSetters.updateSession(taskRunId, {
366-
status: "error",
367-
errorMessage:
368-
"Failed to reconnect to the agent. Please restart the task.",
365+
log.warn("Reconnect returned null, falling back to new session", {
366+
taskId,
367+
taskRunId,
369368
});
369+
await this.recreateOrError(
370+
taskRunId,
371+
taskId,
372+
taskTitle,
373+
repoPath,
374+
auth,
375+
"Failed to start a new session. Please try again.",
376+
);
370377
}
371378
} catch (error) {
372-
this.unsubscribeFromChannel(taskRunId);
373379
const errorMessage =
374380
error instanceof Error ? error.message : String(error);
375-
log.error("Failed to reconnect to session", { taskId, error });
376-
sessionStoreSetters.updateSession(taskRunId, {
377-
status: "error",
378-
errorMessage:
379-
errorMessage || "Failed to reconnect to the agent. Please try again.",
381+
log.warn("Reconnect failed, falling back to new session", {
382+
taskId,
383+
error: errorMessage,
380384
});
385+
await this.recreateOrError(
386+
taskRunId,
387+
taskId,
388+
taskTitle,
389+
repoPath,
390+
auth,
391+
errorMessage || "Failed to reconnect. Please try again.",
392+
);
381393
}
382394
}
383395

396+
private async teardownSession(taskRunId: string): Promise<void> {
397+
try {
398+
await trpcVanilla.agent.cancel.mutate({ sessionId: taskRunId });
399+
} catch (error) {
400+
log.debug("Cancel during teardown failed (session may already be gone)", {
401+
taskRunId,
402+
error: error instanceof Error ? error.message : String(error),
403+
});
404+
}
405+
406+
this.unsubscribeFromChannel(taskRunId);
407+
sessionStoreSetters.removeSession(taskRunId);
408+
useSessionAdapterStore.getState().removeAdapter(taskRunId);
409+
removePersistedConfigOptions(taskRunId);
410+
}
411+
412+
private async recreateOrError(
413+
taskRunId: string,
414+
taskId: string,
415+
taskTitle: string,
416+
repoPath: string,
417+
auth: AuthCredentials,
418+
fallbackMessage: string,
419+
): Promise<void> {
420+
try {
421+
await this.recreateSession(taskRunId, taskId, taskTitle, repoPath, auth);
422+
} catch (recreateError) {
423+
log.error("Failed to recreate session", {
424+
taskId,
425+
error:
426+
recreateError instanceof Error
427+
? recreateError.message
428+
: String(recreateError),
429+
});
430+
this.setErrorSession(taskId, taskRunId, taskTitle, fallbackMessage);
431+
}
432+
}
433+
434+
private setErrorSession(
435+
taskId: string,
436+
taskRunId: string,
437+
taskTitle: string,
438+
errorMessage: string,
439+
): void {
440+
const session = this.createBaseSession(taskRunId, taskId, taskTitle);
441+
session.status = "error";
442+
session.errorMessage = errorMessage;
443+
sessionStoreSetters.setSession(session);
444+
}
445+
446+
private async recreateSession(
447+
oldTaskRunId: string,
448+
taskId: string,
449+
taskTitle: string,
450+
repoPath: string,
451+
auth: AuthCredentials,
452+
): Promise<void> {
453+
await this.teardownSession(oldTaskRunId);
454+
await this.createNewLocalSession(taskId, taskTitle, repoPath, auth);
455+
}
456+
384457
private async createNewLocalSession(
385458
taskId: string,
386459
taskTitle: string,
@@ -468,18 +541,7 @@ export class SessionService {
468541
const session = sessionStoreSetters.getSessionByTaskId(taskId);
469542
if (!session) return;
470543

471-
try {
472-
await trpcVanilla.agent.cancel.mutate({
473-
sessionId: session.taskRunId,
474-
});
475-
} catch (error) {
476-
log.error("Failed to cancel agent session", {
477-
taskRunId: session.taskRunId,
478-
error,
479-
});
480-
}
481-
this.unsubscribeFromChannel(session.taskRunId);
482-
sessionStoreSetters.removeSession(session.taskRunId);
544+
await this.teardownSession(session.taskRunId);
483545
}
484546

485547
// --- Preview Session Management ---
@@ -1233,27 +1295,12 @@ export class SessionService {
12331295

12341296
/**
12351297
* Clear session error and allow retry.
1298+
* Tears down the old session; events are re-fetched from logs during reconnect.
12361299
*/
12371300
async clearSessionError(taskId: string): Promise<void> {
12381301
const session = sessionStoreSetters.getSessionByTaskId(taskId);
12391302
if (session) {
1240-
// Cancel the agent session on the main process
1241-
try {
1242-
await trpcVanilla.agent.cancel.mutate({
1243-
sessionId: session.taskRunId,
1244-
});
1245-
log.info("Cancelled agent session for retry", {
1246-
taskId,
1247-
taskRunId: session.taskRunId,
1248-
});
1249-
} catch (error) {
1250-
log.warn("Failed to cancel agent session during error clear", {
1251-
taskId,
1252-
error,
1253-
});
1254-
}
1255-
this.unsubscribeFromChannel(session.taskRunId);
1256-
sessionStoreSetters.removeSession(session.taskRunId);
1303+
await this.teardownSession(session.taskRunId);
12571304
}
12581305
// Clear from connecting tasks as well
12591306
this.connectingTasks.delete(taskId);

apps/twig/src/renderer/features/sessions/stores/sessionConfigStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ export function setPersistedConfigOptions(
8080
useSessionConfigStore.getState().setConfigOptions(taskRunId, options);
8181
}
8282

83+
/** Non-hook accessor for removing persisted config options */
84+
export function removePersistedConfigOptions(taskRunId: string): void {
85+
useSessionConfigStore.getState().removeConfigOptions(taskRunId);
86+
}
87+
8388
/** Non-hook accessor for updating a single config option value */
8489
export function updatePersistedConfigOptionValue(
8590
taskRunId: string,

apps/twig/src/renderer/features/sidebar/components/items/TaskItem.tsx

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "@phosphor-icons/react";
1212
import type { WorkspaceMode } from "@shared/types";
1313
import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore";
14-
import { useEffect, useMemo, useRef, useState } from "react";
14+
import { useEffect, useRef, useState } from "react";
1515
import { SidebarItem } from "../SidebarItem";
1616

1717
interface TaskItemProps {
@@ -238,31 +238,28 @@ export function TaskItem({
238238
</Tooltip>
239239
);
240240

241-
const endContent = useMemo(() => {
242-
const timestampNode = timestamp ? (
243-
<span className="shrink-0 text-[10px] text-gray-11 group-hover:hidden">
244-
{formatRelativeTime(timestamp)}
245-
</span>
246-
) : null;
247-
248-
const toolbar =
249-
onDelete || onTogglePin ? (
250-
<TaskHoverToolbar
251-
isPinned={isPinned}
252-
onTogglePin={onTogglePin}
253-
onDelete={onDelete}
254-
/>
255-
) : null;
241+
const timestampNode = timestamp ? (
242+
<span className="shrink-0 text-[10px] text-gray-11 group-hover:hidden">
243+
{formatRelativeTime(timestamp)}
244+
</span>
245+
) : null;
256246

257-
if (!timestampNode && !toolbar) return null;
247+
const toolbar =
248+
onDelete || onTogglePin ? (
249+
<TaskHoverToolbar
250+
isPinned={isPinned}
251+
onTogglePin={onTogglePin}
252+
onDelete={onDelete}
253+
/>
254+
) : null;
258255

259-
return (
256+
const endContent =
257+
timestampNode || toolbar ? (
260258
<>
261259
{timestampNode}
262260
{toolbar}
263261
</>
264-
);
265-
}, [timestamp, onDelete, onTogglePin, isPinned]);
262+
) : null;
266263

267264
if (isEditing) {
268265
return (

apps/twig/src/renderer/features/task-detail/components/TaskLogsPanel.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,19 @@ import {
99
} from "@features/sessions/stores/sessionStore";
1010
import { useCwd } from "@features/sidebar/hooks/useCwd";
1111
import { useTaskViewedStore } from "@features/sidebar/stores/taskViewedStore";
12+
import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt";
1213
import { useDeleteTask } from "@features/tasks/hooks/useTasks";
1314
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
1415
import { useConnectivity } from "@hooks/useConnectivity";
1516
import { Box, Button, Flex, Spinner, Text } from "@radix-ui/themes";
1617
import { track } from "@renderer/lib/analytics";
1718
import { logger } from "@renderer/lib/logger";
1819
import { useNavigationStore } from "@renderer/stores/navigationStore";
20+
import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore";
1921
import { trpcVanilla } from "@renderer/trpc/client";
2022
import type { Task } from "@shared/types";
2123
import { useQueryClient } from "@tanstack/react-query";
24+
import { getTaskRepository } from "@utils/repository";
2225
import { toast } from "@utils/toast";
2326
import { useCallback, useEffect, useRef } from "react";
2427
import { ANALYTICS_EVENTS, type FeedbackType } from "@/types/analytics";
@@ -34,6 +37,12 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
3437
const repoPath = useCwd(taskId);
3538
const workspace = useWorkspaceStore((s) => s.workspaces[taskId]);
3639
const queryClient = useQueryClient();
40+
const isWorkspaceLoaded = useWorkspaceStore((s) => s.isLoaded);
41+
const isCreatingWorkspace = useWorkspaceStore((s) => !!s.isCreating[taskId]);
42+
const repoKey = getTaskRepository(task);
43+
const hasDirectoryMapping = useTaskDirectoryStore(
44+
(s) => !!repoKey && repoKey in s.repoDirectories,
45+
);
3746

3847
const session = useSessionForTask(taskId);
3948
const { deleteWithConfirm } = useDeleteTask();
@@ -72,7 +81,7 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
7281
const isInitializing = isCloud
7382
? !session || (events.length === 0 && isCloudRunNotTerminal)
7483
: !session ||
75-
session.status === "connecting" ||
84+
(session.status === "connecting" && events.length === 0) ||
7685
(session.status === "connected" &&
7786
events.length === 0 &&
7887
(isPromptPending ||
@@ -284,6 +293,21 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
284293
[taskId, repoPath],
285294
);
286295

296+
if (
297+
!repoPath &&
298+
isWorkspaceLoaded &&
299+
!hasDirectoryMapping &&
300+
!isCreatingWorkspace
301+
) {
302+
return (
303+
<BackgroundWrapper>
304+
<Box height="100%" width="100%">
305+
<WorkspaceSetupPrompt taskId={taskId} task={task} />
306+
</Box>
307+
</BackgroundWrapper>
308+
);
309+
}
310+
287311
return (
288312
<BackgroundWrapper>
289313
<Flex direction="column" height="100%" width="100%">

0 commit comments

Comments
 (0)