Skip to content

Commit b5bcc3f

Browse files
authored
feat: Parallelize task startup and session creation (#958)
- Agent session: Run fetchMcpToolMetadata, getModelConfigOptions and getAvailableSlashCommands in parallel. Make trySetModel fire-and-forget. Same pattern for resumeSession - Workspace setup: Parallelize loadConfig + buildWorkspaceEnv in local mode (worktree mode already did this) - Reconnect: Batch config option restoration with Promise.allSettled instead of sequential awaits - Task creation saga: Start folder registration in parallel with task creation for new tasks
1 parent 9d23f65 commit b5bcc3f

6 files changed

Lines changed: 201 additions & 116 deletions

File tree

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -376,21 +376,19 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
376376
}
377377
foldersStore.set("taskAssociations", associations);
378378

379-
// Load config and run scripts from main repo
380-
const { config } = await loadConfig(
381-
folderPath,
382-
path.basename(folderPath),
383-
);
379+
// Load config and build env in parallel
380+
const [{ config }, workspaceEnv] = await Promise.all([
381+
loadConfig(folderPath, path.basename(folderPath)),
382+
buildWorkspaceEnv({
383+
taskId,
384+
folderPath,
385+
worktreePath: null,
386+
worktreeName: null,
387+
mode,
388+
}),
389+
]);
384390
let terminalSessionIds: string[] = [];
385391

386-
const workspaceEnv = await buildWorkspaceEnv({
387-
taskId,
388-
folderPath,
389-
worktreePath: null,
390-
worktreeName: null,
391-
mode,
392-
});
393-
394392
// Run init scripts
395393
const initScripts = normalizeScripts(config?.scripts?.init);
396394
if (initScripts.length > 0) {

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

Lines changed: 77 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ interface AuthCredentials {
6363
client: ReturnType<typeof useAuthStore.getState>["client"];
6464
}
6565

66-
interface ConnectParams {
66+
export interface ConnectParams {
6767
task: Task;
6868
repoPath: string;
6969
initialPrompt?: ContentBlock[];
@@ -179,17 +179,18 @@ export class SessionService {
179179
}
180180

181181
if (latestRun?.id && latestRun?.log_url) {
182-
const workspaceResult = await trpcVanilla.workspace.verify.query({
183-
taskId,
184-
});
182+
// Start workspace verify and log fetch in parallel
183+
const [workspaceResult, logResult] = await Promise.all([
184+
trpcVanilla.workspace.verify.query({ taskId }),
185+
this.fetchSessionLogs(latestRun.log_url),
186+
]);
185187

186188
if (!workspaceResult.exists) {
187189
log.warn("Workspace no longer exists, showing error state", {
188190
taskId,
189191
missingPath: workspaceResult.missingPath,
190192
});
191-
const { rawEntries } = await this.fetchSessionLogs(latestRun.log_url);
192-
const events = convertStoredEntriesToEvents(rawEntries);
193+
const events = convertStoredEntriesToEvents(logResult.rawEntries);
193194

194195
const session = this.createBaseSession(
195196
latestRun.id,
@@ -206,29 +207,42 @@ export class SessionService {
206207
sessionStoreSetters.setSession(session);
207208
return;
208209
}
209-
}
210210

211-
if (!getIsOnline()) {
212-
log.info("Skipping connection attempt - offline", { taskId });
213-
const taskRunId = latestRun?.id ?? `offline-${taskId}`;
214-
const session = this.createBaseSession(taskRunId, taskId, taskTitle);
215-
session.status = "disconnected";
216-
session.errorMessage =
217-
"No internet connection. Connect when you're back online.";
218-
sessionStoreSetters.setSession(session);
219-
return;
220-
}
211+
if (!getIsOnline()) {
212+
log.info("Skipping connection attempt - offline", { taskId });
213+
const session = this.createBaseSession(
214+
latestRun.id,
215+
taskId,
216+
taskTitle,
217+
);
218+
session.status = "disconnected";
219+
session.errorMessage =
220+
"No internet connection. Connect when you're back online.";
221+
sessionStoreSetters.setSession(session);
222+
return;
223+
}
221224

222-
if (latestRun?.id && latestRun?.log_url) {
223225
await this.reconnectToLocalSession(
224226
taskId,
225227
latestRun.id,
226228
taskTitle,
227229
latestRun.log_url,
228230
repoPath,
229231
auth,
232+
logResult,
230233
);
231234
} else {
235+
if (!getIsOnline()) {
236+
log.info("Skipping connection attempt - offline", { taskId });
237+
const taskRunId = latestRun?.id ?? `offline-${taskId}`;
238+
const session = this.createBaseSession(taskRunId, taskId, taskTitle);
239+
session.status = "disconnected";
240+
session.errorMessage =
241+
"No internet connection. Connect when you're back online.";
242+
sessionStoreSetters.setSession(session);
243+
return;
244+
}
245+
232246
await this.createNewLocalSession(
233247
taskId,
234248
taskTitle,
@@ -271,9 +285,14 @@ export class SessionService {
271285
logUrl: string,
272286
repoPath: string,
273287
auth: AuthCredentials,
288+
prefetchedLogs?: {
289+
rawEntries: StoredLogEntry[];
290+
sessionId?: string;
291+
adapter?: Adapter;
292+
},
274293
): Promise<void> {
275294
const { rawEntries, sessionId, adapter } =
276-
await this.fetchSessionLogs(logUrl);
295+
prefetchedLogs ?? (await this.fetchSessionLogs(logUrl));
277296
const events = convertStoredEntriesToEvents(rawEntries);
278297

279298
// Resolve adapter from logs or persisted store
@@ -340,26 +359,28 @@ export class SessionService {
340359
setPersistedConfigOptions(taskRunId, configOptions);
341360
}
342361

343-
// Restore persisted config options to server
362+
// Restore persisted config options to server in parallel
344363
if (persistedConfigOptions) {
345-
for (const opt of persistedConfigOptions) {
346-
try {
347-
await trpcVanilla.agent.setConfigOption.mutate({
348-
sessionId: taskRunId,
349-
configId: opt.id,
350-
value: opt.currentValue,
351-
});
352-
} catch (error) {
353-
log.warn(
354-
"Failed to restore persisted config option after reconnect",
355-
{
356-
taskId,
364+
await Promise.all(
365+
persistedConfigOptions.map((opt) =>
366+
trpcVanilla.agent.setConfigOption
367+
.mutate({
368+
sessionId: taskRunId,
357369
configId: opt.id,
358-
error,
359-
},
360-
);
361-
}
362-
}
370+
value: opt.currentValue,
371+
})
372+
.catch((error) => {
373+
log.warn(
374+
"Failed to restore persisted config option after reconnect",
375+
{
376+
taskId,
377+
configId: opt.id,
378+
error,
379+
},
380+
);
381+
}),
382+
),
383+
);
363384
}
364385
} else {
365386
log.warn("Reconnect returned null, falling back to new session", {
@@ -512,25 +533,33 @@ export class SessionService {
512533
execution_type: "local",
513534
});
514535

515-
// Set the model - use passed model if provided, otherwise use store's effective model
536+
// Set model and reasoning level in parallel
516537
const preferredModel =
517538
model ?? useModelsStore.getState().getEffectiveModel();
539+
const configPromises: Promise<void>[] = [];
518540
if (preferredModel) {
519-
await this.setSessionConfigOptionByCategory(
520-
taskId,
521-
"model",
522-
preferredModel,
541+
configPromises.push(
542+
this.setSessionConfigOptionByCategory(
543+
taskId,
544+
"model",
545+
preferredModel,
546+
).catch((err) => log.warn("Failed to set model", { taskId, err })),
523547
);
524548
}
525-
526-
// Set reasoning level if provided (e.g., from Codex adapter's preview session)
527549
if (reasoningLevel) {
528-
await this.setSessionConfigOptionByCategory(
529-
taskId,
530-
"thought_level",
531-
reasoningLevel,
550+
configPromises.push(
551+
this.setSessionConfigOptionByCategory(
552+
taskId,
553+
"thought_level",
554+
reasoningLevel,
555+
).catch((err) =>
556+
log.warn("Failed to set reasoning level", { taskId, err }),
557+
),
532558
);
533559
}
560+
if (configPromises.length > 0) {
561+
await Promise.all(configPromises);
562+
}
534563

535564
if (initialPrompt?.length) {
536565
await this.sendPrompt(taskId, initialPrompt);

apps/twig/src/renderer/sagas/task/task-creation.ts

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { buildPromptBlocks } from "@features/editor/utils/prompt-builder";
2-
import { getSessionService } from "@features/sessions/service/service";
2+
import {
3+
type ConnectParams,
4+
getSessionService,
5+
} from "@features/sessions/service/service";
36
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
47
import { Saga, type SagaLogger } from "@posthog/shared";
58
import type { PostHogAPIClient } from "@renderer/api/posthogClient";
@@ -62,7 +65,14 @@ export class TaskCreationSaga extends Saga<
6265
input: TaskCreationInput,
6366
): Promise<TaskCreationOutput> {
6467
// Step 1: Get or create task
68+
// For new tasks, start folder registration in parallel with task creation
69+
// since folder_registration only needs repoPath (from input), not task.id
6570
const taskId = input.taskId;
71+
const folderPromise =
72+
!taskId && input.repoPath
73+
? this.resolveFolder(input.repoPath)
74+
: undefined;
75+
6676
const task = taskId
6777
? await this.readOnlyStep("fetch_task", () =>
6878
this.deps.posthogClient.getTask(taskId),
@@ -108,21 +118,12 @@ export class TaskCreationSaga extends Saga<
108118

109119
const branch = input.branch ?? task.latest_run?.branch ?? null;
110120

111-
// Get or create folder registration first
112-
const folder = await this.readOnlyStep(
113-
"folder_registration",
114-
async () => {
115-
const folders = await trpcVanilla.folders.getFolders.query();
116-
let existingFolder = folders.find((f) => f.path === repoPath);
117-
118-
if (!existingFolder) {
119-
existingFolder = await trpcVanilla.folders.addFolder.mutate({
120-
folderPath: repoPath,
121-
});
122-
}
123-
return existingFolder;
124-
},
125-
);
121+
// Use the pre-fetched folder if we started it in parallel, otherwise fetch now
122+
const folder = folderPromise
123+
? await this.readOnlyStep("folder_registration", () => folderPromise)
124+
: await this.readOnlyStep("folder_registration", () =>
125+
this.resolveFolder(repoPath),
126+
);
126127

127128
const workspaceInfo = await this.step({
128129
name: "workspace_creation",
@@ -197,25 +198,22 @@ export class TaskCreationSaga extends Saga<
197198
await this.step({
198199
name: "agent_session",
199200
execute: async () => {
200-
// For opening existing tasks, await to ensure chat history loads
201-
// For creating new tasks, we can proceed without waiting
202-
if (input.taskId) {
203-
await getSessionService().connectToTask({
204-
task,
205-
repoPath: agentCwd ?? "",
206-
});
207-
} else {
208-
// Don't await for create - allows faster navigation to task page
209-
getSessionService().connectToTask({
210-
task,
211-
repoPath: agentCwd ?? "",
212-
initialPrompt,
213-
executionMode: input.executionMode,
214-
adapter: input.adapter,
215-
model: input.model,
216-
reasoningLevel: input.reasoningLevel,
217-
});
218-
}
201+
// Fire-and-forget for both open and create paths.
202+
// The UI handles "connecting" state with a spinner (TaskLogsPanel),
203+
// so we don't need to block the saga on the full reconnect chain.
204+
const connectParams: ConnectParams = {
205+
task,
206+
repoPath: agentCwd ?? "",
207+
};
208+
if (initialPrompt) connectParams.initialPrompt = initialPrompt;
209+
if (input.executionMode)
210+
connectParams.executionMode = input.executionMode;
211+
if (input.adapter) connectParams.adapter = input.adapter;
212+
if (input.model) connectParams.model = input.model;
213+
if (input.reasoningLevel)
214+
connectParams.reasoningLevel = input.reasoningLevel;
215+
216+
getSessionService().connectToTask(connectParams);
219217
return { taskId: task.id };
220218
},
221219
rollback: async ({ taskId }) => {
@@ -246,6 +244,18 @@ export class TaskCreationSaga extends Saga<
246244
});
247245
}
248246

247+
private async resolveFolder(repoPath: string) {
248+
const folders = await trpcVanilla.folders.getFolders.query();
249+
let existingFolder = folders.find((f) => f.path === repoPath);
250+
251+
if (!existingFolder) {
252+
existingFolder = await trpcVanilla.folders.addFolder.mutate({
253+
folderPath: repoPath,
254+
});
255+
}
256+
return existingFolder;
257+
}
258+
249259
private async createTask(input: TaskCreationInput): Promise<Task> {
250260
let repository = input.repository;
251261

0 commit comments

Comments
 (0)