Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions apps/twig/src/main/services/workspace/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,21 +376,19 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
}
foldersStore.set("taskAssociations", associations);

// Load config and run scripts from main repo
const { config } = await loadConfig(
folderPath,
path.basename(folderPath),
);
// Load config and build env in parallel
const [{ config }, workspaceEnv] = await Promise.all([
loadConfig(folderPath, path.basename(folderPath)),
buildWorkspaceEnv({
taskId,
folderPath,
worktreePath: null,
worktreeName: null,
mode,
}),
]);
let terminalSessionIds: string[] = [];

const workspaceEnv = await buildWorkspaceEnv({
taskId,
folderPath,
worktreePath: null,
worktreeName: null,
mode,
});

// Run init scripts
const initScripts = normalizeScripts(config?.scripts?.init);
if (initScripts.length > 0) {
Expand Down
125 changes: 77 additions & 48 deletions apps/twig/src/renderer/features/sessions/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ interface AuthCredentials {
client: ReturnType<typeof useAuthStore.getState>["client"];
}

interface ConnectParams {
export interface ConnectParams {
task: Task;
repoPath: string;
initialPrompt?: ContentBlock[];
Expand Down Expand Up @@ -179,17 +179,18 @@ export class SessionService {
}

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

if (!workspaceResult.exists) {
log.warn("Workspace no longer exists, showing error state", {
taskId,
missingPath: workspaceResult.missingPath,
});
const { rawEntries } = await this.fetchSessionLogs(latestRun.log_url);
const events = convertStoredEntriesToEvents(rawEntries);
const events = convertStoredEntriesToEvents(logResult.rawEntries);

const session = this.createBaseSession(
latestRun.id,
Expand All @@ -206,29 +207,42 @@ export class SessionService {
sessionStoreSetters.setSession(session);
return;
}
}

if (!getIsOnline()) {
log.info("Skipping connection attempt - offline", { taskId });
const taskRunId = latestRun?.id ?? `offline-${taskId}`;
const session = this.createBaseSession(taskRunId, taskId, taskTitle);
session.status = "disconnected";
session.errorMessage =
"No internet connection. Connect when you're back online.";
sessionStoreSetters.setSession(session);
return;
}
if (!getIsOnline()) {
log.info("Skipping connection attempt - offline", { taskId });
const session = this.createBaseSession(
latestRun.id,
taskId,
taskTitle,
);
session.status = "disconnected";
session.errorMessage =
"No internet connection. Connect when you're back online.";
sessionStoreSetters.setSession(session);
return;
}

if (latestRun?.id && latestRun?.log_url) {
await this.reconnectToLocalSession(
taskId,
latestRun.id,
taskTitle,
latestRun.log_url,
repoPath,
auth,
logResult,
);
} else {
if (!getIsOnline()) {
log.info("Skipping connection attempt - offline", { taskId });
const taskRunId = latestRun?.id ?? `offline-${taskId}`;
const session = this.createBaseSession(taskRunId, taskId, taskTitle);
session.status = "disconnected";
session.errorMessage =
"No internet connection. Connect when you're back online.";
sessionStoreSetters.setSession(session);
return;
}

await this.createNewLocalSession(
taskId,
taskTitle,
Expand Down Expand Up @@ -271,9 +285,14 @@ export class SessionService {
logUrl: string,
repoPath: string,
auth: AuthCredentials,
prefetchedLogs?: {
rawEntries: StoredLogEntry[];
sessionId?: string;
adapter?: Adapter;
},
): Promise<void> {
const { rawEntries, sessionId, adapter } =
await this.fetchSessionLogs(logUrl);
prefetchedLogs ?? (await this.fetchSessionLogs(logUrl));
const events = convertStoredEntriesToEvents(rawEntries);

// Resolve adapter from logs or persisted store
Expand Down Expand Up @@ -340,26 +359,28 @@ export class SessionService {
setPersistedConfigOptions(taskRunId, configOptions);
}

// Restore persisted config options to server
// Restore persisted config options to server in parallel
if (persistedConfigOptions) {
for (const opt of persistedConfigOptions) {
try {
await trpcVanilla.agent.setConfigOption.mutate({
sessionId: taskRunId,
configId: opt.id,
value: opt.currentValue,
});
} catch (error) {
log.warn(
"Failed to restore persisted config option after reconnect",
{
taskId,
await Promise.all(
persistedConfigOptions.map((opt) =>
trpcVanilla.agent.setConfigOption
.mutate({
sessionId: taskRunId,
configId: opt.id,
error,
},
);
}
}
value: opt.currentValue,
})
.catch((error) => {
log.warn(
"Failed to restore persisted config option after reconnect",
{
taskId,
configId: opt.id,
error,
},
);
}),
),
);
}
} else {
log.warn("Reconnect returned null, falling back to new session", {
Expand Down Expand Up @@ -512,25 +533,33 @@ export class SessionService {
execution_type: "local",
});

// Set the model - use passed model if provided, otherwise use store's effective model
// Set model and reasoning level in parallel
const preferredModel =
model ?? useModelsStore.getState().getEffectiveModel();
const configPromises: Promise<void>[] = [];
if (preferredModel) {
await this.setSessionConfigOptionByCategory(
taskId,
"model",
preferredModel,
configPromises.push(
this.setSessionConfigOptionByCategory(
taskId,
"model",
preferredModel,
).catch((err) => log.warn("Failed to set model", { taskId, err })),
);
}

// Set reasoning level if provided (e.g., from Codex adapter's preview session)
if (reasoningLevel) {
await this.setSessionConfigOptionByCategory(
taskId,
"thought_level",
reasoningLevel,
configPromises.push(
this.setSessionConfigOptionByCategory(
taskId,
"thought_level",
reasoningLevel,
).catch((err) =>
log.warn("Failed to set reasoning level", { taskId, err }),
),
);
}
if (configPromises.length > 0) {
await Promise.all(configPromises);
}

if (initialPrompt?.length) {
await this.sendPrompt(taskId, initialPrompt);
Expand Down
80 changes: 45 additions & 35 deletions apps/twig/src/renderer/sagas/task/task-creation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { buildPromptBlocks } from "@features/editor/utils/prompt-builder";
import { getSessionService } from "@features/sessions/service/service";
import {
type ConnectParams,
getSessionService,
} from "@features/sessions/service/service";
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
import { Saga, type SagaLogger } from "@posthog/shared";
import type { PostHogAPIClient } from "@renderer/api/posthogClient";
Expand Down Expand Up @@ -62,7 +65,14 @@ export class TaskCreationSaga extends Saga<
input: TaskCreationInput,
): Promise<TaskCreationOutput> {
// Step 1: Get or create task
// For new tasks, start folder registration in parallel with task creation
// since folder_registration only needs repoPath (from input), not task.id
const taskId = input.taskId;
const folderPromise =
!taskId && input.repoPath
? this.resolveFolder(input.repoPath)
: undefined;

const task = taskId
? await this.readOnlyStep("fetch_task", () =>
this.deps.posthogClient.getTask(taskId),
Expand Down Expand Up @@ -108,21 +118,12 @@ export class TaskCreationSaga extends Saga<

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

// Get or create folder registration first
const folder = await this.readOnlyStep(
"folder_registration",
async () => {
const folders = await trpcVanilla.folders.getFolders.query();
let existingFolder = folders.find((f) => f.path === repoPath);

if (!existingFolder) {
existingFolder = await trpcVanilla.folders.addFolder.mutate({
folderPath: repoPath,
});
}
return existingFolder;
},
);
// Use the pre-fetched folder if we started it in parallel, otherwise fetch now
const folder = folderPromise
? await this.readOnlyStep("folder_registration", () => folderPromise)
: await this.readOnlyStep("folder_registration", () =>
this.resolveFolder(repoPath),
);

const workspaceInfo = await this.step({
name: "workspace_creation",
Expand Down Expand Up @@ -197,25 +198,22 @@ export class TaskCreationSaga extends Saga<
await this.step({
name: "agent_session",
execute: async () => {
// For opening existing tasks, await to ensure chat history loads
// For creating new tasks, we can proceed without waiting
if (input.taskId) {
await getSessionService().connectToTask({
task,
repoPath: agentCwd ?? "",
});
} else {
// Don't await for create - allows faster navigation to task page
getSessionService().connectToTask({
task,
repoPath: agentCwd ?? "",
initialPrompt,
executionMode: input.executionMode,
adapter: input.adapter,
model: input.model,
reasoningLevel: input.reasoningLevel,
});
}
// Fire-and-forget for both open and create paths.
// The UI handles "connecting" state with a spinner (TaskLogsPanel),
// so we don't need to block the saga on the full reconnect chain.
const connectParams: ConnectParams = {
task,
repoPath: agentCwd ?? "",
};
if (initialPrompt) connectParams.initialPrompt = initialPrompt;
if (input.executionMode)
connectParams.executionMode = input.executionMode;
if (input.adapter) connectParams.adapter = input.adapter;
if (input.model) connectParams.model = input.model;
if (input.reasoningLevel)
connectParams.reasoningLevel = input.reasoningLevel;

getSessionService().connectToTask(connectParams);
return { taskId: task.id };
},
rollback: async ({ taskId }) => {
Expand Down Expand Up @@ -246,6 +244,18 @@ export class TaskCreationSaga extends Saga<
});
}

private async resolveFolder(repoPath: string) {
const folders = await trpcVanilla.folders.getFolders.query();
let existingFolder = folders.find((f) => f.path === repoPath);

if (!existingFolder) {
existingFolder = await trpcVanilla.folders.addFolder.mutate({
folderPath: repoPath,
});
}
return existingFolder;
}

private async createTask(input: TaskCreationInput): Promise<Task> {
let repository = input.repository;

Expand Down
Loading
Loading