diff --git a/apps/array/src/main/lib/timing.ts b/apps/array/src/main/lib/timing.ts new file mode 100644 index 00000000..eede1aa4 --- /dev/null +++ b/apps/array/src/main/lib/timing.ts @@ -0,0 +1,15 @@ +import type { ScopedLogger } from "./logger.js"; + +/** + * Creates a timing helper that logs execution duration. + * @param log - Scoped logger to use for timing output + * @returns A function that times async operations and logs the result + */ +export function createTimer(log: ScopedLogger) { + return async (label: string, fn: () => Promise): Promise => { + const start = Date.now(); + const result = await fn(); + log.info(`[timing] ${label}: ${Date.now() - start}ms`); + return result; + }; +} diff --git a/apps/array/src/main/services/workspace/service.ts b/apps/array/src/main/services/workspace/service.ts index 5f6e9f22..5a514169 100644 --- a/apps/array/src/main/services/workspace/service.ts +++ b/apps/array/src/main/services/workspace/service.ts @@ -299,23 +299,21 @@ export class WorkspaceService extends TypedEventEmitter } foldersStore.set("taskAssociations", associations); - // Load config and run init scripts - const { config } = await loadConfig( - worktree.worktreePath, - worktree.worktreeName, - ); - const initScripts = normalizeScripts(config?.scripts?.init); + // Load config and build env in parallel + const [{ config }, workspaceEnv] = await Promise.all([ + loadConfig(worktree.worktreePath, worktree.worktreeName), + buildWorkspaceEnv({ + taskId, + folderPath, + worktreePath: worktree.worktreePath, + worktreeName: worktree.worktreeName, + mode, + }), + ]); + const initScripts = normalizeScripts(config?.scripts?.init); let terminalSessionIds: string[] = []; - const workspaceEnv = await buildWorkspaceEnv({ - taskId, - folderPath, - worktreePath: worktree.worktreePath, - worktreeName: worktree.worktreeName, - mode, - }); - if (initScripts.length > 0) { log.info( `Running ${initScripts.length} init script(s) for task ${taskId}`, diff --git a/apps/array/src/renderer/sagas/task/task-creation.ts b/apps/array/src/renderer/sagas/task/task-creation.ts index 6ed1dfb6..e3390a6f 100644 --- a/apps/array/src/renderer/sagas/task/task-creation.ts +++ b/apps/array/src/renderer/sagas/task/task-creation.ts @@ -63,7 +63,9 @@ export class TaskCreationSaga extends Saga< // Step 2: Resolve repoPath - input takes precedence, then stored mappings // Wait for workspace store to load first (it loads async on init) - await this.waitForWorkspacesLoaded(); + await this.readOnlyStep("wait_workspaces_loaded", () => + this.waitForWorkspacesLoaded(), + ); const repoKey = getTaskRepository(task); const repoPath = @@ -99,14 +101,20 @@ export class TaskCreationSaga extends Saga< const branch = input.branch ?? task.latest_run?.branch ?? null; // Get or create folder registration first - const folders = await trpcVanilla.folders.getFolders.query(); - let folder = folders.find((f) => f.path === repoPath); + const folder = await this.readOnlyStep( + "folder_registration", + async () => { + const folders = await trpcVanilla.folders.getFolders.query(); + let existingFolder = folders.find((f) => f.path === repoPath); - if (!folder) { - folder = await trpcVanilla.folders.addFolder.mutate({ - folderPath: repoPath, - }); - } + if (!existingFolder) { + existingFolder = await trpcVanilla.folders.addFolder.mutate({ + folderPath: repoPath, + }); + } + return existingFolder; + }, + ); const workspaceInfo = await this.step({ name: "workspace_creation", @@ -167,10 +175,12 @@ export class TaskCreationSaga extends Saga< if (shouldConnect) { const initialPrompt = !input.taskId && input.autoRun && input.content - ? await buildPromptBlocks( - input.content, - input.filePaths ?? [], - agentCwd ?? "", + ? await this.readOnlyStep("build_prompt_blocks", () => + buildPromptBlocks( + input.content!, + input.filePaths ?? [], + agentCwd ?? "", + ), ) : undefined; diff --git a/apps/array/src/shared/lib/saga.ts b/apps/array/src/shared/lib/saga.ts index 5e6732eb..a24508f9 100644 --- a/apps/array/src/shared/lib/saga.ts +++ b/apps/array/src/shared/lib/saga.ts @@ -49,6 +49,7 @@ export abstract class Saga { rollback: () => Promise; }> = []; private currentStepName = "unknown"; + private stepTimings: Array<{ name: string; durationMs: number }> = []; protected readonly log: SagaLogger; constructor(logger?: SagaLogger) { @@ -62,15 +63,20 @@ export abstract class Saga { async run(input: TInput): Promise> { this.completedSteps = []; this.currentStepName = "unknown"; + this.stepTimings = []; + const sagaStart = performance.now(); this.log.info("Starting saga", { sagaName: this.constructor.name }); try { const result = await this.execute(input); - this.log.info("Saga completed successfully", { + const totalDuration = performance.now() - sagaStart; + this.log.debug("Saga completed successfully", { sagaName: this.constructor.name, stepsCompleted: this.completedSteps.length, + totalDurationMs: Math.round(totalDuration), + stepTimings: this.stepTimings, }); return { success: true, data: result }; @@ -110,7 +116,12 @@ export abstract class Saga { this.currentStepName = config.name; this.log.debug(`Executing step: ${config.name}`); + const stepStart = performance.now(); const result = await config.execute(); + const durationMs = Math.round(performance.now() - stepStart); + + this.stepTimings.push({ name: config.name, durationMs }); + this.log.debug(`Step completed: ${config.name}`, { durationMs }); // Store rollback action with the result bound this.completedSteps.push({ @@ -118,8 +129,6 @@ export abstract class Saga { rollback: () => config.rollback(result), }); - this.log.debug(`Step completed: ${config.name}`); - return result; } @@ -138,8 +147,13 @@ export abstract class Saga { ): Promise { this.currentStepName = name; this.log.debug(`Executing read-only step: ${name}`); + + const stepStart = performance.now(); const result = await execute(); - this.log.debug(`Read-only step completed: ${name}`); + const durationMs = Math.round(performance.now() - stepStart); + + this.stepTimings.push({ name, durationMs }); + this.log.debug(`Read-only step completed: ${name}`, { durationMs }); return result; } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ddabb20c..a10cb3a1 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -263,8 +263,6 @@ export class Agent { ): Promise { await this._configureLlmGateway(); - const task = await this.fetchTask(taskId); - const taskSlug = (task as any).slug || task.id; const isCloudMode = options.isCloudMode ?? false; const _cwd = options.repositoryPath || this.workingDirectory; @@ -275,7 +273,7 @@ export class Agent { framework: options.framework, sessionStore: this.sessionStore, sessionId: taskRunId, - taskId: task.id, + taskId, }); const sendNotification: SendNotification = async (method, params) => { @@ -291,7 +289,10 @@ export class Agent { runId: taskRunId, }); + // Only fetch task when we need the slug for git branch creation if (!options.skipGitBranch) { + const task = options.task ?? (await this.fetchTask(taskId)); + const taskSlug = (task as any).slug || task.id; try { await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification); } catch (error) { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 25637fc4..e676a53d 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -142,6 +142,7 @@ export interface TaskExecutionOptions { canUseTool?: CanUseTool; skipGitBranch?: boolean; // Skip creating a task-specific git branch framework?: "claude" | "codex"; // Agent framework to use (defaults to "claude") + task?: Task; // Pre-fetched task to avoid redundant API call } export interface ExecutionResult { diff --git a/packages/agent/src/worktree-manager.ts b/packages/agent/src/worktree-manager.ts index e25e6b20..d11217bf 100644 --- a/packages/agent/src/worktree-manager.ts +++ b/packages/agent/src/worktree-manager.ts @@ -605,48 +605,68 @@ export class WorktreeManager { } private async getDefaultBranch(): Promise { - try { - const remoteBranch = await this.runGitCommand( - "symbolic-ref refs/remotes/origin/HEAD", - ); - return remoteBranch.replace("refs/remotes/origin/", ""); - } catch { - // Fallback: check if main exists, otherwise use master - try { - await this.runGitCommand("rev-parse --verify main"); - return "main"; - } catch { - try { - await this.runGitCommand("rev-parse --verify master"); - return "master"; - } catch { - throw new Error( - "Cannot determine default branch. No main or master branch found.", - ); - } - } + // Try all methods in parallel for speed + const [symbolicRef, mainExists, masterExists] = await Promise.allSettled([ + this.runGitCommand("symbolic-ref refs/remotes/origin/HEAD"), + this.runGitCommand("rev-parse --verify main"), + this.runGitCommand("rev-parse --verify master"), + ]); + + // Prefer symbolic ref (most accurate) + if (symbolicRef.status === "fulfilled") { + return symbolicRef.value.replace("refs/remotes/origin/", ""); + } + + // Fallback to main if it exists + if (mainExists.status === "fulfilled") { + return "main"; + } + + // Fallback to master if it exists + if (masterExists.status === "fulfilled") { + return "master"; } + + throw new Error( + "Cannot determine default branch. No main or master branch found.", + ); } async createWorktree(options?: { baseBranch?: string; }): Promise { + const totalStart = Date.now(); + + // Run setup tasks in parallel for speed + const setupPromises: Promise[] = []; + // Only modify .git/info/exclude when using in-repo storage if (!this.usesExternalPath()) { - await this.ensureArrayDirIgnored(); - } - - // Ensure the worktree folder exists when using external path - if (this.usesExternalPath()) { + setupPromises.push(this.ensureArrayDirIgnored()); + } else { + // Ensure the worktree folder exists when using external path const folderPath = this.getWorktreeFolderPath(); - await fs.mkdir(folderPath, { recursive: true }); + setupPromises.push(fs.mkdir(folderPath, { recursive: true })); } - // Generate unique worktree name - const worktreeName = await this.generateUniqueWorktreeName(); + // Generate unique worktree name (in parallel with above) + const worktreeNamePromise = this.generateUniqueWorktreeName(); + setupPromises.push(worktreeNamePromise); + + // Get default branch in parallel if not provided + const baseBranchPromise = options?.baseBranch + ? Promise.resolve(options.baseBranch) + : this.getDefaultBranch(); + setupPromises.push(baseBranchPromise); + + // Wait for all setup to complete + await Promise.all(setupPromises); + const setupTime = Date.now() - totalStart; + + const worktreeName = await worktreeNamePromise; + const baseBranch = await baseBranchPromise; const worktreePath = this.getWorktreePath(worktreeName); const branchName = `array/${worktreeName}`; - const baseBranch = options?.baseBranch ?? (await this.getDefaultBranch()); this.logger.info("Creating worktree", { worktreeName, @@ -654,21 +674,24 @@ export class WorktreeManager { branchName, baseBranch, external: this.usesExternalPath(), + setupTimeMs: setupTime, }); // Create the worktree with a new branch + const gitStart = Date.now(); if (this.usesExternalPath()) { // Use absolute path for external worktrees await this.runGitCommand( - `worktree add -b "${branchName}" "${worktreePath}" "${baseBranch}"`, + `worktree add --quiet -b "${branchName}" "${worktreePath}" "${baseBranch}"`, ); } else { // Use relative path from repo root for in-repo worktrees const relativePath = `${WORKTREE_FOLDER_NAME}/${worktreeName}`; await this.runGitCommand( - `worktree add -b "${branchName}" "./${relativePath}" "${baseBranch}"`, + `worktree add --quiet -b "${branchName}" "./${relativePath}" "${baseBranch}"`, ); } + const gitTime = Date.now() - gitStart; const createdAt = new Date().toISOString(); @@ -676,6 +699,9 @@ export class WorktreeManager { worktreeName, worktreePath, branchName, + setupTimeMs: setupTime, + gitWorktreeAddMs: gitTime, + totalMs: Date.now() - totalStart, }); return {