Skip to content

Commit c36d334

Browse files
committed
Fix resuming existing tasks locally from remote
1 parent 1989816 commit c36d334

4 files changed

Lines changed: 196 additions & 41 deletions

File tree

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

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
430430
const mockNodeDir = this.setupMockNodeEnvironment(taskRunId);
431431
this.setupEnvironment(credentials, mockNodeDir);
432432

433+
// Abort controller that fires when the agent process exits, used to
434+
// race initialize/newSession/extMethod so they fail immediately instead
435+
// of hanging forever when the subprocess dies early.
436+
const processExitController = new AbortController();
437+
433438
const agent = new Agent({
434439
posthog: {
435440
apiUrl: credentials.apiHost,
@@ -458,6 +463,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
458463
},
459464
onProcessExited: (pid) => {
460465
this.processTracking.unregister(pid, "agent-exited");
466+
log.warn("Agent process exited during session setup", {
467+
taskRunId,
468+
taskId,
469+
pid,
470+
});
471+
processExitController.abort(
472+
new Error("Agent process exited unexpectedly"),
473+
);
461474
},
462475
},
463476
});
@@ -469,16 +482,36 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
469482
clientStreams,
470483
);
471484

472-
await connection.initialize({
473-
protocolVersion: PROTOCOL_VERSION,
474-
clientCapabilities: {
475-
fs: {
476-
readTextFile: true,
477-
writeTextFile: true,
485+
// Helper: race a promise against the agent process exiting so we
486+
// never hang on a dead subprocess.
487+
const raceProcessExit = <T>(promise: Promise<T>): Promise<T> => {
488+
if (processExitController.signal.aborted) {
489+
return Promise.reject(processExitController.signal.reason);
490+
}
491+
return Promise.race([
492+
promise,
493+
new Promise<never>((_, reject) => {
494+
processExitController.signal.addEventListener(
495+
"abort",
496+
() => reject(processExitController.signal.reason),
497+
{ once: true },
498+
);
499+
}),
500+
]);
501+
};
502+
503+
await raceProcessExit(
504+
connection.initialize({
505+
protocolVersion: PROTOCOL_VERSION,
506+
clientCapabilities: {
507+
fs: {
508+
readTextFile: true,
509+
writeTextFile: true,
510+
},
511+
terminal: true,
478512
},
479-
terminal: true,
480-
},
481-
});
513+
}),
514+
);
482515

483516
const mcpServers = this.buildMcpServers(credentials);
484517

@@ -489,9 +522,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
489522

490523
if (isReconnect) {
491524
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
492-
const resumeResponse = await connection.extMethod(
493-
"_posthog/session/resume",
494-
{
525+
const resumeResponse = await raceProcessExit(
526+
connection.extMethod("_posthog/session/resume", {
495527
sessionId: taskRunId,
496528
cwd: repoPath,
497529
mcpServers,
@@ -507,7 +539,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
507539
},
508540
}),
509541
},
510-
},
542+
}),
511543
);
512544
const resumeMeta = resumeResponse?._meta as
513545
| {
@@ -521,21 +553,23 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
521553
currentModelId = resumeMeta?.models?.currentModelId;
522554
} else {
523555
const systemPrompt = this.buildPostHogSystemPrompt(credentials);
524-
const newSessionResponse = await connection.newSession({
525-
cwd: repoPath,
526-
mcpServers,
527-
_meta: {
528-
sessionId: taskRunId,
529-
model,
530-
systemPrompt,
531-
...(executionMode && { initialModeId: executionMode }),
532-
...(additionalDirectories?.length && {
533-
claudeCode: {
534-
options: { additionalDirectories },
535-
},
536-
}),
537-
},
538-
});
556+
const newSessionResponse = await raceProcessExit(
557+
connection.newSession({
558+
cwd: repoPath,
559+
mcpServers,
560+
_meta: {
561+
sessionId: taskRunId,
562+
model,
563+
systemPrompt,
564+
...(executionMode && { initialModeId: executionMode }),
565+
...(additionalDirectories?.length && {
566+
claudeCode: {
567+
options: { additionalDirectories },
568+
},
569+
}),
570+
},
571+
}),
572+
);
539573
availableModels = newSessionResponse.models?.availableModels;
540574
currentModelId = newSessionResponse.models?.currentModelId;
541575
}

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)