Skip to content

Commit 7e33e3f

Browse files
committed
Fix resuming existing tasks locally from remote
1 parent 737d28c commit 7e33e3f

4 files changed

Lines changed: 194 additions & 37 deletions

File tree

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

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
449449
taskRunId,
450450
});
451451

452+
// Abort controller that fires when the agent process exits, used to
453+
// race initialize/newSession/extMethod so they fail immediately instead
454+
// of hanging forever when the subprocess dies early.
455+
const processExitController = new AbortController();
456+
452457
const agent = new Agent({
453458
posthog: {
454459
apiUrl: credentials.apiHost,
@@ -486,6 +491,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
486491
},
487492
onProcessExited: (pid) => {
488493
this.processTracking.unregister(pid, "agent-exited");
494+
log.warn("Agent process exited during session setup", {
495+
taskRunId,
496+
taskId,
497+
pid,
498+
});
499+
processExitController.abort(
500+
new Error("Agent process exited unexpectedly"),
501+
);
489502
},
490503
},
491504
});
@@ -497,16 +510,36 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
497510
clientStreams,
498511
);
499512

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

511544
const mcpServers =
512545
adapter === "codex" ? [] : this.buildMcpServers(credentials);
@@ -524,9 +557,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
524557
agentSessionId = config.sessionId;
525558
} else if (isReconnect && adapter !== "codex") {
526559
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
527-
const resumeResponse = await connection.extMethod(
528-
"_posthog/session/resume",
529-
{
560+
const resumeResponse = await raceProcessExit(
561+
connection.extMethod("_posthog/session/resume", {
530562
sessionId: taskRunId,
531563
cwd: repoPath,
532564
mcpServers,
@@ -543,7 +575,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
543575
},
544576
}),
545577
},
546-
},
578+
}),
547579
);
548580
const resumeMeta = resumeResponse?._meta as
549581
| {
@@ -554,19 +586,21 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
554586
agentSessionId = (resumeResponse?.sessionId as string) ?? taskRunId;
555587
} else {
556588
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
557-
const newSessionResponse = await connection.newSession({
558-
cwd: repoPath,
559-
mcpServers,
560-
_meta: {
561-
taskRunId,
562-
systemPrompt,
563-
...(additionalDirectories?.length && {
564-
claudeCode: {
565-
options: { additionalDirectories },
566-
},
567-
}),
568-
},
569-
});
589+
const newSessionResponse = await raceProcessExit(
590+
connection.newSession({
591+
cwd: repoPath,
592+
mcpServers,
593+
_meta: {
594+
taskRunId,
595+
systemPrompt,
596+
...(additionalDirectories?.length && {
597+
claudeCode: {
598+
options: { additionalDirectories },
599+
},
600+
}),
601+
},
602+
}),
603+
);
570604
configOptions = newSessionResponse.configOptions ?? undefined;
571605
agentSessionId = newSessionResponse.sessionId;
572606
}

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -331,23 +331,29 @@ export class SessionService {
331331
}
332332
}
333333
} else {
334-
this.unsubscribeFromChannel(taskRunId);
335-
sessionStoreSetters.updateSession(taskRunId, {
336-
status: "error",
337-
errorMessage:
338-
"Failed to reconnect to the agent. Please restart the task.",
334+
// Reconnect returned null — agent process likely exited because the
335+
// local Claude Code session no longer exists on disk.
336+
// Fall back to starting a fresh session.
337+
log.warn("Reconnect returned null, falling back to new session", {
338+
taskId,
339+
taskRunId,
339340
});
341+
this.unsubscribeFromChannel(taskRunId);
342+
sessionStoreSetters.removeSession(taskRunId);
343+
await this.createNewLocalSession(taskId, taskTitle, repoPath, auth);
340344
}
341345
} catch (error) {
342-
this.unsubscribeFromChannel(taskRunId);
346+
// Reconnect failed (e.g. agent process exited unexpectedly).
347+
// Fall back to starting a fresh session.
343348
const errorMessage =
344349
error instanceof Error ? error.message : String(error);
345-
log.error("Failed to reconnect to session", { taskId, error });
346-
sessionStoreSetters.updateSession(taskRunId, {
347-
status: "error",
348-
errorMessage:
349-
errorMessage || "Failed to reconnect to the agent. Please try again.",
350+
log.warn("Reconnect failed, falling back to new session", {
351+
taskId,
352+
error: errorMessage,
350353
});
354+
this.unsubscribeFromChannel(taskRunId);
355+
sessionStoreSetters.removeSession(taskRunId);
356+
await this.createNewLocalSession(taskId, taskTitle, repoPath, auth);
351357
}
352358
}
353359

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)