Skip to content

Commit 8e5679b

Browse files
committed
Fix resuming existing tasks locally from remote
1 parent 08b96a0 commit 8e5679b

File tree

4 files changed

+195
-40
lines changed

4 files changed

+195
-40
lines changed

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

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
423423
const mockNodeDir = this.setupMockNodeEnvironment(taskRunId);
424424
this.setupEnvironment(credentials, mockNodeDir);
425425

426+
// Abort controller that fires when the agent process exits, used to
427+
// race initialize/newSession/extMethod so they fail immediately instead
428+
// of hanging forever when the subprocess dies early.
429+
const processExitController = new AbortController();
430+
426431
const agent = new Agent({
427432
posthog: {
428433
apiUrl: credentials.apiHost,
@@ -451,6 +456,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
451456
},
452457
onProcessExited: (pid) => {
453458
this.processTracking.unregister(pid, "agent-exited");
459+
log.warn("Agent process exited during session setup", {
460+
taskRunId,
461+
taskId,
462+
pid,
463+
});
464+
processExitController.abort(
465+
new Error("Agent process exited unexpectedly"),
466+
);
454467
},
455468
},
456469
});
@@ -462,16 +475,36 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
462475
clientStreams,
463476
);
464477

465-
await connection.initialize({
466-
protocolVersion: PROTOCOL_VERSION,
467-
clientCapabilities: {
468-
fs: {
469-
readTextFile: true,
470-
writeTextFile: true,
478+
// Helper: race a promise against the agent process exiting so we
479+
// never hang on a dead subprocess.
480+
const raceProcessExit = <T>(promise: Promise<T>): Promise<T> => {
481+
if (processExitController.signal.aborted) {
482+
return Promise.reject(processExitController.signal.reason);
483+
}
484+
return Promise.race([
485+
promise,
486+
new Promise<never>((_, reject) => {
487+
processExitController.signal.addEventListener(
488+
"abort",
489+
() => reject(processExitController.signal.reason),
490+
{ once: true },
491+
);
492+
}),
493+
]);
494+
};
495+
496+
await raceProcessExit(
497+
connection.initialize({
498+
protocolVersion: PROTOCOL_VERSION,
499+
clientCapabilities: {
500+
fs: {
501+
readTextFile: true,
502+
writeTextFile: true,
503+
},
504+
terminal: true,
471505
},
472-
terminal: true,
473-
},
474-
});
506+
}),
507+
);
475508

476509
const mcpServers = this.buildMcpServers(credentials);
477510

@@ -481,9 +514,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
481514
let currentModelId: string | undefined;
482515

483516
if (isReconnect) {
484-
const resumeResponse = await connection.extMethod(
485-
"_posthog/session/resume",
486-
{
517+
const resumeResponse = await raceProcessExit(
518+
connection.extMethod("_posthog/session/resume", {
487519
sessionId: taskRunId,
488520
cwd: repoPath,
489521
mcpServers,
@@ -498,7 +530,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
498530
},
499531
}),
500532
},
501-
},
533+
}),
502534
);
503535
const resumeMeta = resumeResponse?._meta as
504536
| {
@@ -511,20 +543,22 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
511543
availableModels = resumeMeta?.models?.availableModels;
512544
currentModelId = resumeMeta?.models?.currentModelId;
513545
} else {
514-
const newSessionResponse = await connection.newSession({
515-
cwd: repoPath,
516-
mcpServers,
517-
_meta: {
518-
sessionId: taskRunId,
519-
model,
520-
...(executionMode && { initialModeId: executionMode }),
521-
...(additionalDirectories?.length && {
522-
claudeCode: {
523-
options: { additionalDirectories },
524-
},
525-
}),
526-
},
527-
});
546+
const newSessionResponse = await raceProcessExit(
547+
connection.newSession({
548+
cwd: repoPath,
549+
mcpServers,
550+
_meta: {
551+
sessionId: taskRunId,
552+
model,
553+
...(executionMode && { initialModeId: executionMode }),
554+
...(additionalDirectories?.length && {
555+
claudeCode: {
556+
options: { additionalDirectories },
557+
},
558+
}),
559+
},
560+
}),
561+
);
528562
availableModels = newSessionResponse.models?.availableModels;
529563
currentModelId = newSessionResponse.models?.currentModelId;
530564
}

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -737,25 +737,29 @@ const useStore = create<SessionStore>()(
737737
}
738738
}
739739
} else {
740-
unsubscribeFromChannel(taskRunId);
741-
updateSession(taskRunId, {
742-
status: "error",
743-
errorMessage:
744-
"Failed to reconnect to the agent. Please restart the task.",
740+
// Reconnect returned null — agent process likely exited because the
741+
// local Claude Code session no longer exists on disk.
742+
// Fall back to starting a fresh session.
743+
log.warn("Reconnect returned null, falling back to new session", {
744+
taskId,
745+
taskRunId,
745746
});
747+
unsubscribeFromChannel(taskRunId);
748+
removeSession(taskRunId);
749+
await createNewLocalSession(taskId, taskTitle, repoPath, auth);
746750
}
747751
} catch (error) {
748-
// Handle reconnection errors - session already added, just update status
749-
unsubscribeFromChannel(taskRunId);
752+
// Reconnect failed (e.g. agent process exited unexpectedly).
753+
// Fall back to starting a fresh session.
750754
const errorMessage =
751755
error instanceof Error ? error.message : String(error);
752-
log.error("Failed to reconnect to session", { taskId, error });
753-
updateSession(taskRunId, {
754-
status: "error",
755-
errorMessage:
756-
errorMessage ||
757-
"Failed to reconnect to the agent. Please try again.",
756+
log.warn("Reconnect failed, falling back to new session", {
757+
taskId,
758+
error: errorMessage,
758759
});
760+
unsubscribeFromChannel(taskRunId);
761+
removeSession(taskRunId);
762+
await createNewLocalSession(taskId, taskTitle, repoPath, auth);
759763
}
760764
};
761765

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import {
88
} from "@features/sessions/stores/sessionStore";
99
import { useCwd } from "@features/sidebar/hooks/useCwd";
1010
import { useTaskViewedStore } from "@features/sidebar/stores/taskViewedStore";
11+
import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt";
1112
import { useDeleteTask } from "@features/tasks/hooks/useTasks";
1213
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
1314
import { useConnectivity } from "@hooks/useConnectivity";
1415
import { Box } from "@radix-ui/themes";
1516
import { logger } from "@renderer/lib/logger";
1617
import { useNavigationStore } from "@renderer/stores/navigationStore";
18+
import { useTaskDirectoryStore } from "@renderer/stores/taskDirectoryStore";
1719
import { trpcVanilla } from "@renderer/trpc/client";
1820
import type { Task } from "@shared/types";
21+
import { getTaskRepository } from "@utils/repository";
1922
import { toast } from "@utils/toast";
2023
import { useCallback, useEffect, useRef } from "react";
2124

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

3342
const session = useSessionForTask(taskId);
3443
const {
@@ -187,6 +196,21 @@ export function TaskLogsPanel({ taskId, task }: TaskLogsPanelProps) {
187196
[taskId, repoPath, appendUserShellExecute],
188197
);
189198

199+
if (
200+
!repoPath &&
201+
isWorkspaceLoaded &&
202+
!hasDirectoryMapping &&
203+
!isCreatingWorkspace
204+
) {
205+
return (
206+
<BackgroundWrapper>
207+
<Box height="100%" width="100%">
208+
<WorkspaceSetupPrompt taskId={taskId} task={task} />
209+
</Box>
210+
</BackgroundWrapper>
211+
);
212+
}
213+
190214
return (
191215
<BackgroundWrapper>
192216
<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)