Skip to content

Commit 0e2bccd

Browse files
authored
fix: Improve agent subprocess error handling (#489)
...
1 parent c3e678f commit 0e2bccd

3 files changed

Lines changed: 162 additions & 9 deletions

File tree

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

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
useSessionActions,
88
} from "@features/sessions/stores/sessionStore";
99
import type { Plan } from "@features/sessions/types";
10-
import { Box, ContextMenu, Flex } from "@radix-ui/themes";
10+
import { Warning } from "@phosphor-icons/react";
11+
import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes";
1112
import {
1213
type AcpMessage,
1314
isJsonRpcNotification,
@@ -42,8 +43,15 @@ interface SessionViewProps {
4243
onCancelPrompt: () => void;
4344
repoPath?: string | null;
4445
isCloud?: boolean;
46+
hasError?: boolean;
47+
errorMessage?: string;
48+
onRetry?: () => void;
49+
onDelete?: () => void;
4550
}
4651

52+
const DEFAULT_ERROR_MESSAGE =
53+
"Failed to resume this session. The working directory may have been deleted. Please start a new task.";
54+
4755
export function SessionView({
4856
events,
4957
taskId,
@@ -54,6 +62,10 @@ export function SessionView({
5462
onCancelPrompt,
5563
repoPath,
5664
isCloud = false,
65+
hasError = false,
66+
errorMessage = DEFAULT_ERROR_MESSAGE,
67+
onRetry,
68+
onDelete,
5769
}: SessionViewProps) {
5870
const showRawLogs = useShowRawLogs();
5971
const { setShowRawLogs } = useSessionViewActions();
@@ -233,7 +245,44 @@ export function SessionView({
233245

234246
<PlanStatusBar plan={latestPlan} />
235247

236-
{firstPendingPermission ? (
248+
{hasError ? (
249+
<Flex
250+
align="center"
251+
justify="center"
252+
direction="column"
253+
gap="2"
254+
className="absolute inset-0"
255+
>
256+
<Warning size={32} weight="duotone" color="var(--red-9)" />
257+
<Text size="3" weight="medium" color="red">
258+
Session Error
259+
</Text>
260+
<Text
261+
size="2"
262+
align="center"
263+
className="max-w-md px-4 text-gray-11"
264+
>
265+
{errorMessage}
266+
</Text>
267+
<Flex gap="2" mt="2">
268+
{onRetry && (
269+
<Button variant="soft" size="2" onClick={onRetry}>
270+
Retry
271+
</Button>
272+
)}
273+
{onDelete && (
274+
<Button
275+
variant="soft"
276+
size="2"
277+
color="red"
278+
onClick={onDelete}
279+
>
280+
Delete Task
281+
</Button>
282+
)}
283+
</Flex>
284+
</Flex>
285+
) : firstPendingPermission ? (
237286
<InlinePermissionSelector
238287
title={firstPendingPermission.title}
239288
options={firstPendingPermission.options}

apps/array/src/renderer/features/sessions/stores/sessionStore.ts

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface AgentSession {
4646
events: AcpMessage[];
4747
startedAt: number;
4848
status: "connecting" | "connected" | "disconnected" | "error";
49+
errorMessage?: string;
4950
isPromptPending: boolean;
5051
isCloud: boolean;
5152
logUrl?: string;
@@ -91,6 +92,7 @@ interface SessionActions {
9192
customInput?: string,
9293
) => Promise<void>;
9394
cancelPermission: (taskId: string, toolCallId: string) => Promise<void>;
95+
clearSessionError: (taskId: string) => void;
9496
}
9597

9698
interface AuthCredentials {
@@ -160,6 +162,14 @@ function subscribeToChannel(taskRunId: string) {
160162
},
161163
onError: (err) => {
162164
log.error("Session subscription error", { taskRunId, error: err });
165+
useStore.setState((state) => {
166+
const session = state.sessions[taskRunId];
167+
if (session) {
168+
session.status = "error";
169+
session.errorMessage =
170+
"Lost connection to the agent. Please restart the task.";
171+
}
172+
});
163173
},
164174
},
165175
);
@@ -640,7 +650,11 @@ const useStore = create<SessionStore>()(
640650
}
641651
} else {
642652
unsubscribeFromChannel(taskRunId);
643-
removeSession(taskRunId);
653+
updateSession(taskRunId, {
654+
status: "error",
655+
errorMessage:
656+
"Failed to reconnect to the agent. Please restart the task.",
657+
});
644658
}
645659
};
646660

@@ -652,14 +666,14 @@ const useStore = create<SessionStore>()(
652666
executionMode?: "plan" | "acceptEdits",
653667
) => {
654668
if (!auth.client) {
655-
log.error("API client not available");
656-
return;
669+
throw new Error(
670+
"Unable to reach server. Please check your connection.",
671+
);
657672
}
658673

659674
const taskRun = await auth.client.createTaskRun(taskId);
660675
if (!taskRun?.id) {
661-
log.error("Task run created without ID");
662-
return;
676+
throw new Error("Failed to create task run. Please try again.");
663677
}
664678

665679
const persistedMode = getPersistedTaskMode(taskId);
@@ -776,6 +790,12 @@ const useStore = create<SessionStore>()(
776790
const auth = getAuthCredentials();
777791
if (!auth) {
778792
log.error("Missing auth credentials");
793+
const taskRunId = latestRun?.id ?? `error-${taskId}`;
794+
const session = createBaseSession(taskRunId, taskId, isCloud);
795+
session.status = "error";
796+
session.errorMessage =
797+
"Authentication required. Please sign in to continue.";
798+
addSession(session);
779799
return;
780800
}
781801

@@ -787,6 +807,32 @@ const useStore = create<SessionStore>()(
787807
taskDescription,
788808
);
789809
} else if (latestRun?.id && latestRun?.log_url) {
810+
// Check if workspace still exists before attempting reconnect
811+
const workspaceExists = await trpcVanilla.workspace.verify.query({
812+
taskId,
813+
});
814+
815+
if (!workspaceExists) {
816+
// Workspace was deleted - show historical logs in error state
817+
log.warn("Workspace no longer exists, showing error state", {
818+
taskId,
819+
});
820+
const { rawEntries } = await fetchSessionLogs(
821+
latestRun.log_url,
822+
);
823+
const events = convertStoredEntriesToEvents(rawEntries);
824+
825+
const session = createBaseSession(latestRun.id, taskId, false);
826+
session.events = events;
827+
session.logUrl = latestRun.log_url;
828+
session.status = "error";
829+
session.errorMessage =
830+
"The working directory for this task no longer exists. Please start a new task.";
831+
832+
addSession(session);
833+
return;
834+
}
835+
790836
await reconnectToLocalSession(
791837
taskId,
792838
latestRun.id,
@@ -807,6 +853,27 @@ const useStore = create<SessionStore>()(
807853
const message =
808854
error instanceof Error ? error.message : String(error);
809855
log.error("Failed to connect to task", { message });
856+
857+
// Create session in error state so user sees what happened
858+
const taskRunId = latestRun?.id ?? `error-${taskId}`;
859+
const session = createBaseSession(taskRunId, taskId, isCloud);
860+
session.status = "error";
861+
session.errorMessage = `Failed to connect to the agent: ${message}`;
862+
863+
// Try to load historical logs if available
864+
if (latestRun?.log_url) {
865+
try {
866+
const { rawEntries } = await fetchSessionLogs(
867+
latestRun.log_url,
868+
);
869+
session.events = convertStoredEntriesToEvents(rawEntries);
870+
session.logUrl = latestRun.log_url;
871+
} catch {
872+
// Ignore log fetch errors - just show error state without logs
873+
}
874+
}
875+
876+
addSession(session);
810877
} finally {
811878
connectAttempts.delete(taskId);
812879
}
@@ -1106,6 +1173,14 @@ const useStore = create<SessionStore>()(
11061173
});
11071174
}
11081175
},
1176+
1177+
clearSessionError: (taskId: string) => {
1178+
const session = getSessionByTaskId(taskId);
1179+
if (session) {
1180+
removeSession(session.taskRunId);
1181+
}
1182+
connectAttempts.delete(taskId);
1183+
},
11091184
},
11101185
};
11111186
}),

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { useSettingsStore } from "@features/settings/stores/settingsStore";
99
import { useTaskViewedStore } from "@features/sidebar/stores/taskViewedStore";
1010
import { useTaskData } from "@features/task-detail/hooks/useTaskData";
11+
import { useDeleteTask } from "@features/tasks/hooks/useTasks";
1112
import {
1213
selectWorktreePath,
1314
useWorkspaceStore,
@@ -32,13 +33,17 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
3233
const repoPath = worktreePath ?? taskData.repoPath;
3334

3435
const session = useSessionForTask(taskId);
35-
const { connectToTask, sendPrompt, cancelPrompt } = useSessionActions();
36+
const { connectToTask, sendPrompt, cancelPrompt, clearSessionError } =
37+
useSessionActions();
38+
const { deleteWithConfirm } = useDeleteTask();
3639
const markActivity = useTaskViewedStore((state) => state.markActivity);
3740
const markAsViewed = useTaskViewedStore((state) => state.markAsViewed);
3841
const requestFocus = useDraftStore((s) => s.actions.requestFocus);
3942

4043
const isRunning =
4144
session?.status === "connected" || session?.status === "connecting";
45+
const hasError = session?.status === "error";
46+
const errorMessage = session?.errorMessage;
4247

4348
const events = session?.events ?? [];
4449
const isPromptPending = session?.isPromptPending ?? false;
@@ -54,7 +59,12 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
5459
if (!repoPath) return;
5560
if (isConnecting.current) return;
5661

57-
if (session?.status === "connected" || session?.status === "connecting") {
62+
// Don't reconnect if already connected, connecting, or in error state
63+
if (
64+
session?.status === "connected" ||
65+
session?.status === "connecting" ||
66+
session?.status === "error"
67+
) {
5868
return;
5969
}
6070

@@ -122,6 +132,21 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
122132

123133
const { appendUserShellExecute } = useSessionActions();
124134

135+
const handleRetry = useCallback(() => {
136+
if (!repoPath) return;
137+
clearSessionError(taskId);
138+
connectToTask({ task, repoPath });
139+
}, [taskId, repoPath, task, clearSessionError, connectToTask]);
140+
141+
const handleDelete = useCallback(() => {
142+
const hasWorktree = !!worktreePath;
143+
deleteWithConfirm({
144+
taskId,
145+
taskTitle: task.title ?? task.description ?? "Untitled",
146+
hasWorktree,
147+
});
148+
}, [taskId, task, worktreePath, deleteWithConfirm]);
149+
125150
const handleBashCommand = useCallback(
126151
async (command: string) => {
127152
if (!repoPath) return;
@@ -152,6 +177,10 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
152177
onCancelPrompt={handleCancelPrompt}
153178
repoPath={repoPath}
154179
isCloud={session?.isCloud ?? false}
180+
hasError={hasError}
181+
errorMessage={errorMessage}
182+
onRetry={handleRetry}
183+
onDelete={handleDelete}
155184
/>
156185
</Box>
157186
</BackgroundWrapper>

0 commit comments

Comments
 (0)