Skip to content

Commit 8b518fb

Browse files
committed
Fix resuming existing tasks locally from remote
1 parent 44696e6 commit 8b518fb

4 files changed

Lines changed: 195 additions & 38 deletions

File tree

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

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
453453
taskRunId,
454454
});
455455

456+
// Abort controller that fires when the agent process exits, used to
457+
// race initialize/newSession/extMethod so they fail immediately instead
458+
// of hanging forever when the subprocess dies early.
459+
const processExitController = new AbortController();
460+
456461
const agent = new Agent({
457462
posthog: {
458463
apiUrl: credentials.apiHost,
@@ -490,6 +495,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
490495
},
491496
onProcessExited: (pid) => {
492497
this.processTracking.unregister(pid, "agent-exited");
498+
log.warn("Agent process exited during session setup", {
499+
taskRunId,
500+
taskId,
501+
pid,
502+
});
503+
processExitController.abort(
504+
new Error("Agent process exited unexpectedly"),
505+
);
493506
},
494507
},
495508
});
@@ -501,16 +514,36 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
501514
clientStreams,
502515
);
503516

504-
await connection.initialize({
505-
protocolVersion: PROTOCOL_VERSION,
506-
clientCapabilities: {
507-
fs: {
508-
readTextFile: true,
509-
writeTextFile: true,
517+
// Helper: race a promise against the agent process exiting so we
518+
// never hang on a dead subprocess.
519+
const raceProcessExit = <T>(promise: Promise<T>): Promise<T> => {
520+
if (processExitController.signal.aborted) {
521+
return Promise.reject(processExitController.signal.reason);
522+
}
523+
return Promise.race([
524+
promise,
525+
new Promise<never>((_, reject) => {
526+
processExitController.signal.addEventListener(
527+
"abort",
528+
() => reject(processExitController.signal.reason),
529+
{ once: true },
530+
);
531+
}),
532+
]);
533+
};
534+
535+
await raceProcessExit(
536+
connection.initialize({
537+
protocolVersion: PROTOCOL_VERSION,
538+
clientCapabilities: {
539+
fs: {
540+
readTextFile: true,
541+
writeTextFile: true,
542+
},
543+
terminal: true,
510544
},
511-
terminal: true,
512-
},
513-
});
545+
}),
546+
);
514547

515548
const mcpServers =
516549
adapter === "codex" ? [] : this.buildMcpServers(credentials);
@@ -528,9 +561,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
528561
agentSessionId = config.sessionId;
529562
} else if (isReconnect && adapter !== "codex") {
530563
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
531-
const resumeResponse = await connection.extMethod(
532-
"_posthog/session/resume",
533-
{
564+
const resumeResponse = await raceProcessExit(
565+
connection.extMethod("_posthog/session/resume", {
534566
sessionId: taskRunId,
535567
cwd: repoPath,
536568
mcpServers,
@@ -548,7 +580,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
548580
},
549581
}),
550582
},
551-
},
583+
}),
552584
);
553585
const resumeMeta = resumeResponse?._meta as
554586
| {
@@ -559,20 +591,22 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
559591
agentSessionId = (resumeResponse?.sessionId as string) ?? taskRunId;
560592
} else {
561593
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
562-
const newSessionResponse = await connection.newSession({
563-
cwd: repoPath,
564-
mcpServers,
565-
_meta: {
566-
taskRunId,
567-
systemPrompt,
568-
...(permissionMode && { permissionMode }),
569-
...(additionalDirectories?.length && {
570-
claudeCode: {
571-
options: { additionalDirectories },
572-
},
573-
}),
574-
},
575-
});
594+
const newSessionResponse = await raceProcessExit(
595+
connection.newSession({
596+
cwd: repoPath,
597+
mcpServers,
598+
_meta: {
599+
taskRunId,
600+
systemPrompt,
601+
...(permissionMode && { permissionMode }),
602+
...(additionalDirectories?.length && {
603+
claudeCode: {
604+
options: { additionalDirectories },
605+
},
606+
}),
607+
},
608+
}),
609+
);
576610
configOptions = newSessionResponse.configOptions ?? undefined;
577611
agentSessionId = newSessionResponse.sessionId;
578612
}

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -337,23 +337,29 @@ export class SessionService {
337337
}
338338
}
339339
} else {
340-
this.unsubscribeFromChannel(taskRunId);
341-
sessionStoreSetters.updateSession(taskRunId, {
342-
status: "error",
343-
errorMessage:
344-
"Failed to reconnect to the agent. Please restart the task.",
340+
// Reconnect returned null — agent process likely exited because the
341+
// local Claude Code session no longer exists on disk.
342+
// Fall back to starting a fresh session.
343+
log.warn("Reconnect returned null, falling back to new session", {
344+
taskId,
345+
taskRunId,
345346
});
347+
this.unsubscribeFromChannel(taskRunId);
348+
sessionStoreSetters.removeSession(taskRunId);
349+
await this.createNewLocalSession(taskId, taskTitle, repoPath, auth);
346350
}
347351
} catch (error) {
348-
this.unsubscribeFromChannel(taskRunId);
352+
// Reconnect failed (e.g. agent process exited unexpectedly).
353+
// Fall back to starting a fresh session.
349354
const errorMessage =
350355
error instanceof Error ? error.message : String(error);
351-
log.error("Failed to reconnect to session", { taskId, error });
352-
sessionStoreSetters.updateSession(taskRunId, {
353-
status: "error",
354-
errorMessage:
355-
errorMessage || "Failed to reconnect to the agent. Please try again.",
356+
log.warn("Reconnect failed, falling back to new session", {
357+
taskId,
358+
error: errorMessage,
356359
});
360+
this.unsubscribeFromChannel(taskRunId);
361+
sessionStoreSetters.removeSession(taskRunId);
362+
await this.createNewLocalSession(taskId, taskTitle, repoPath, auth);
357363
}
358364
}
359365

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ 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 } from "@radix-ui/themes";
1617
import { logger } from "@renderer/lib/logger";
1718
import { useNavigationStore } from "@renderer/stores/navigationStore";
19+
import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore";
1820
import { trpcVanilla } from "@renderer/trpc/client";
1921
import type { Task } from "@shared/types";
22+
import { getTaskRepository } from "@utils/repository";
2023
import { toast } from "@utils/toast";
2124
import { useCallback, useEffect, useRef } from "react";
2225

@@ -30,6 +33,12 @@ interface TaskLogsPanelProps {
3033
export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
3134
const repoPath = useCwd(taskId);
3235
const workspace = useWorkspaceStore((s) => s.workspaces[taskId]);
36+
const isWorkspaceLoaded = useWorkspaceStore((s) => s.isLoaded);
37+
const isCreatingWorkspace = useWorkspaceStore((s) => !!s.isCreating[taskId]);
38+
const repoKey = getTaskRepository(task);
39+
const hasDirectoryMapping = useTaskDirectoryStore(
40+
(s) => !!repoKey && repoKey in s.repoDirectories,
41+
);
3342

3443
const session = useSessionForTask(taskId);
3544
const { deleteWithConfirm } = useDeleteTask();
@@ -204,6 +213,21 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
204213
[taskId, repoPath],
205214
);
206215

216+
if (
217+
!repoPath &&
218+
isWorkspaceLoaded &&
219+
!hasDirectoryMapping &&
220+
!isCreatingWorkspace
221+
) {
222+
return (
223+
<BackgroundWrapper>
224+
<Box height="100%" width="100%">
225+
<WorkspaceSetupPrompt taskId={taskId} task={task} />
226+
</Box>
227+
</BackgroundWrapper>
228+
);
229+
}
230+
207231
return (
208232
<BackgroundWrapper>
209233
<Box height="100%" width="100%">
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { FolderPicker } from "@features/folder-picker/components/FolderPicker";
2+
import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore";
3+
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
4+
import { Folder } from "@phosphor-icons/react";
5+
import { Box, Code, Flex, Spinner, Text } from "@radix-ui/themes";
6+
import { logger } from "@renderer/lib/logger";
7+
import { useRegisteredFoldersStore } from "@renderer/stores/registeredFoldersStore";
8+
import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore";
9+
import type { Task } from "@shared/types";
10+
import { getTaskRepository } from "@utils/repository";
11+
import { useCallback, useState } from "react";
12+
13+
const log = logger.scope("workspace-setup-prompt");
14+
15+
interface WorkspaceSetupPromptProps {
16+
taskId: string;
17+
task: Task;
18+
}
19+
20+
export function WorkspaceSetupPrompt({
21+
taskId,
22+
task,
23+
}: WorkspaceSetupPromptProps) {
24+
const [isSettingUp, setIsSettingUp] = useState(false);
25+
const [selectedPath, setSelectedPath] = useState("");
26+
const repository = getTaskRepository(task);
27+
28+
const handleFolderSelect = useCallback(
29+
async (path: string) => {
30+
setSelectedPath(path);
31+
setIsSettingUp(true);
32+
33+
try {
34+
const addFolder = useRegisteredFoldersStore.getState().addFolder;
35+
await addFolder(path);
36+
37+
if (repository) {
38+
useTaskDirectoryStore.getState().setRepoDirectory(repository, path);
39+
}
40+
41+
await useWorkspaceStore
42+
.getState()
43+
.ensureWorkspace(taskId, path, "worktree");
44+
useTaskExecutionStore.getState().setRepoPath(taskId, path);
45+
46+
log.info("Workspace setup complete", { taskId, path });
47+
} catch (error) {
48+
log.error("Failed to set up workspace", error);
49+
setIsSettingUp(false);
50+
}
51+
},
52+
[taskId, repository],
53+
);
54+
55+
return (
56+
<Flex
57+
align="center"
58+
justify="center"
59+
direction="column"
60+
gap="3"
61+
className="absolute inset-0"
62+
>
63+
{isSettingUp ? (
64+
<>
65+
<Spinner size="3" />
66+
<Text size="2" className="text-gray-11">
67+
Setting up workspace...
68+
</Text>
69+
</>
70+
) : (
71+
<>
72+
<Folder size={32} weight="duotone" className="text-gray-9" />
73+
<Text size="3" weight="medium" className="text-gray-12">
74+
Select a repository folder
75+
</Text>
76+
{repository && (
77+
<Text size="2" className="text-gray-11">
78+
This task is linked to <Code>{repository}</Code>
79+
</Text>
80+
)}
81+
<Box mt="1">
82+
<FolderPicker
83+
value={selectedPath}
84+
onChange={handleFolderSelect}
85+
placeholder="Select folder..."
86+
size="2"
87+
/>
88+
</Box>
89+
</>
90+
)}
91+
</Flex>
92+
);
93+
}

0 commit comments

Comments
 (0)