Skip to content

Commit cb69d4a

Browse files
committed
Implement saga time tracking
- Use pre-fetched task in agent to avoid redundant API call - Try all methods in parallel in worktree manager for speed - Run worktree git commands in quiet mode
1 parent 8d8f0e9 commit cb69d4a

File tree

7 files changed

+129
-64
lines changed

7 files changed

+129
-64
lines changed

apps/array/src/main/lib/timing.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { ScopedLogger } from "./logger.js";
2+
3+
/**
4+
* Creates a timing helper that logs execution duration.
5+
* @param log - Scoped logger to use for timing output
6+
* @returns A function that times async operations and logs the result
7+
*/
8+
export function createTimer(log: ScopedLogger) {
9+
return async <T>(label: string, fn: () => Promise<T>): Promise<T> => {
10+
const start = Date.now();
11+
const result = await fn();
12+
log.info(`[timing] ${label}: ${Date.now() - start}ms`);
13+
return result;
14+
};
15+
}

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

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -299,23 +299,21 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
299299
}
300300
foldersStore.set("taskAssociations", associations);
301301

302-
// Load config and run init scripts
303-
const { config } = await loadConfig(
304-
worktree.worktreePath,
305-
worktree.worktreeName,
306-
);
307-
const initScripts = normalizeScripts(config?.scripts?.init);
302+
// Load config and build env in parallel
303+
const [{ config }, workspaceEnv] = await Promise.all([
304+
loadConfig(worktree.worktreePath, worktree.worktreeName),
305+
buildWorkspaceEnv({
306+
taskId,
307+
folderPath,
308+
worktreePath: worktree.worktreePath,
309+
worktreeName: worktree.worktreeName,
310+
mode,
311+
}),
312+
]);
308313

314+
const initScripts = normalizeScripts(config?.scripts?.init);
309315
let terminalSessionIds: string[] = [];
310316

311-
const workspaceEnv = await buildWorkspaceEnv({
312-
taskId,
313-
folderPath,
314-
worktreePath: worktree.worktreePath,
315-
worktreeName: worktree.worktreeName,
316-
mode,
317-
});
318-
319317
if (initScripts.length > 0) {
320318
log.info(
321319
`Running ${initScripts.length} init script(s) for task ${taskId}`,

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ export class TaskCreationSaga extends Saga<
6363

6464
// Step 2: Resolve repoPath - input takes precedence, then stored mappings
6565
// Wait for workspace store to load first (it loads async on init)
66-
await this.waitForWorkspacesLoaded();
66+
await this.readOnlyStep("wait_workspaces_loaded", () =>
67+
this.waitForWorkspacesLoaded(),
68+
);
6769

6870
const repoKey = getTaskRepository(task);
6971
const repoPath =
@@ -99,14 +101,20 @@ export class TaskCreationSaga extends Saga<
99101
const branch = input.branch ?? task.latest_run?.branch ?? null;
100102

101103
// Get or create folder registration first
102-
const folders = await trpcVanilla.folders.getFolders.query();
103-
let folder = folders.find((f) => f.path === repoPath);
104+
const folder = await this.readOnlyStep(
105+
"folder_registration",
106+
async () => {
107+
const folders = await trpcVanilla.folders.getFolders.query();
108+
let existingFolder = folders.find((f) => f.path === repoPath);
104109

105-
if (!folder) {
106-
folder = await trpcVanilla.folders.addFolder.mutate({
107-
folderPath: repoPath,
108-
});
109-
}
110+
if (!existingFolder) {
111+
existingFolder = await trpcVanilla.folders.addFolder.mutate({
112+
folderPath: repoPath,
113+
});
114+
}
115+
return existingFolder;
116+
},
117+
);
110118

111119
const workspaceInfo = await this.step({
112120
name: "workspace_creation",
@@ -167,10 +175,12 @@ export class TaskCreationSaga extends Saga<
167175
if (shouldConnect) {
168176
const initialPrompt =
169177
!input.taskId && input.autoRun && input.content
170-
? await buildPromptBlocks(
171-
input.content,
172-
input.filePaths ?? [],
173-
agentCwd ?? "",
178+
? await this.readOnlyStep("build_prompt_blocks", () =>
179+
buildPromptBlocks(
180+
input.content!,
181+
input.filePaths ?? [],
182+
agentCwd ?? "",
183+
),
174184
)
175185
: undefined;
176186

apps/array/src/shared/lib/saga.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export abstract class Saga<TInput, TOutput> {
4949
rollback: () => Promise<void>;
5050
}> = [];
5151
private currentStepName = "unknown";
52+
private stepTimings: Array<{ name: string; durationMs: number }> = [];
5253
protected readonly log: SagaLogger;
5354

5455
constructor(logger?: SagaLogger) {
@@ -62,15 +63,20 @@ export abstract class Saga<TInput, TOutput> {
6263
async run(input: TInput): Promise<SagaResult<TOutput>> {
6364
this.completedSteps = [];
6465
this.currentStepName = "unknown";
66+
this.stepTimings = [];
6567

68+
const sagaStart = performance.now();
6669
this.log.info("Starting saga", { sagaName: this.constructor.name });
6770

6871
try {
6972
const result = await this.execute(input);
7073

71-
this.log.info("Saga completed successfully", {
74+
const totalDuration = performance.now() - sagaStart;
75+
this.log.debug("Saga completed successfully", {
7276
sagaName: this.constructor.name,
7377
stepsCompleted: this.completedSteps.length,
78+
totalDurationMs: Math.round(totalDuration),
79+
stepTimings: this.stepTimings,
7480
});
7581

7682
return { success: true, data: result };
@@ -110,16 +116,19 @@ export abstract class Saga<TInput, TOutput> {
110116
this.currentStepName = config.name;
111117
this.log.debug(`Executing step: ${config.name}`);
112118

119+
const stepStart = performance.now();
113120
const result = await config.execute();
121+
const durationMs = Math.round(performance.now() - stepStart);
122+
123+
this.stepTimings.push({ name: config.name, durationMs });
124+
this.log.debug(`Step completed: ${config.name}`, { durationMs });
114125

115126
// Store rollback action with the result bound
116127
this.completedSteps.push({
117128
name: config.name,
118129
rollback: () => config.rollback(result),
119130
});
120131

121-
this.log.debug(`Step completed: ${config.name}`);
122-
123132
return result;
124133
}
125134

@@ -138,8 +147,13 @@ export abstract class Saga<TInput, TOutput> {
138147
): Promise<T> {
139148
this.currentStepName = name;
140149
this.log.debug(`Executing read-only step: ${name}`);
150+
151+
const stepStart = performance.now();
141152
const result = await execute();
142-
this.log.debug(`Read-only step completed: ${name}`);
153+
const durationMs = Math.round(performance.now() - stepStart);
154+
155+
this.stepTimings.push({ name, durationMs });
156+
this.log.debug(`Read-only step completed: ${name}`, { durationMs });
143157
return result;
144158
}
145159

packages/agent/src/agent.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,6 @@ export class Agent {
263263
): Promise<InProcessAcpConnection> {
264264
await this._configureLlmGateway();
265265

266-
const task = await this.fetchTask(taskId);
267-
const taskSlug = (task as any).slug || task.id;
268266
const isCloudMode = options.isCloudMode ?? false;
269267
const _cwd = options.repositoryPath || this.workingDirectory;
270268

@@ -275,7 +273,7 @@ export class Agent {
275273
framework: options.framework,
276274
sessionStore: this.sessionStore,
277275
sessionId: taskRunId,
278-
taskId: task.id,
276+
taskId,
279277
});
280278

281279
const sendNotification: SendNotification = async (method, params) => {
@@ -291,7 +289,10 @@ export class Agent {
291289
runId: taskRunId,
292290
});
293291

292+
// Only fetch task when we need the slug for git branch creation
294293
if (!options.skipGitBranch) {
294+
const task = options.task ?? (await this.fetchTask(taskId));
295+
const taskSlug = (task as any).slug || task.id;
295296
try {
296297
await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification);
297298
} catch (error) {

packages/agent/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export interface TaskExecutionOptions {
142142
canUseTool?: CanUseTool;
143143
skipGitBranch?: boolean; // Skip creating a task-specific git branch
144144
framework?: "claude" | "codex"; // Agent framework to use (defaults to "claude")
145+
task?: Task; // Pre-fetched task to avoid redundant API call
145146
}
146147

147148
export interface ExecutionResult {

packages/agent/src/worktree-manager.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -605,77 +605,103 @@ export class WorktreeManager {
605605
}
606606

607607
private async getDefaultBranch(): Promise<string> {
608-
try {
609-
const remoteBranch = await this.runGitCommand(
610-
"symbolic-ref refs/remotes/origin/HEAD",
611-
);
612-
return remoteBranch.replace("refs/remotes/origin/", "");
613-
} catch {
614-
// Fallback: check if main exists, otherwise use master
615-
try {
616-
await this.runGitCommand("rev-parse --verify main");
617-
return "main";
618-
} catch {
619-
try {
620-
await this.runGitCommand("rev-parse --verify master");
621-
return "master";
622-
} catch {
623-
throw new Error(
624-
"Cannot determine default branch. No main or master branch found.",
625-
);
626-
}
627-
}
608+
// Try all methods in parallel for speed
609+
const [symbolicRef, mainExists, masterExists] = await Promise.allSettled([
610+
this.runGitCommand("symbolic-ref refs/remotes/origin/HEAD"),
611+
this.runGitCommand("rev-parse --verify main"),
612+
this.runGitCommand("rev-parse --verify master"),
613+
]);
614+
615+
// Prefer symbolic ref (most accurate)
616+
if (symbolicRef.status === "fulfilled") {
617+
return symbolicRef.value.replace("refs/remotes/origin/", "");
618+
}
619+
620+
// Fallback to main if it exists
621+
if (mainExists.status === "fulfilled") {
622+
return "main";
623+
}
624+
625+
// Fallback to master if it exists
626+
if (masterExists.status === "fulfilled") {
627+
return "master";
628628
}
629+
630+
throw new Error(
631+
"Cannot determine default branch. No main or master branch found.",
632+
);
629633
}
630634

631635
async createWorktree(options?: {
632636
baseBranch?: string;
633637
}): Promise<WorktreeInfo> {
638+
const totalStart = Date.now();
639+
640+
// Run setup tasks in parallel for speed
641+
const setupPromises: Promise<unknown>[] = [];
642+
634643
// Only modify .git/info/exclude when using in-repo storage
635644
if (!this.usesExternalPath()) {
636-
await this.ensureArrayDirIgnored();
637-
}
638-
639-
// Ensure the worktree folder exists when using external path
640-
if (this.usesExternalPath()) {
645+
setupPromises.push(this.ensureArrayDirIgnored());
646+
} else {
647+
// Ensure the worktree folder exists when using external path
641648
const folderPath = this.getWorktreeFolderPath();
642-
await fs.mkdir(folderPath, { recursive: true });
649+
setupPromises.push(fs.mkdir(folderPath, { recursive: true }));
643650
}
644651

645-
// Generate unique worktree name
646-
const worktreeName = await this.generateUniqueWorktreeName();
652+
// Generate unique worktree name (in parallel with above)
653+
const worktreeNamePromise = this.generateUniqueWorktreeName();
654+
setupPromises.push(worktreeNamePromise);
655+
656+
// Get default branch in parallel if not provided
657+
const baseBranchPromise = options?.baseBranch
658+
? Promise.resolve(options.baseBranch)
659+
: this.getDefaultBranch();
660+
setupPromises.push(baseBranchPromise);
661+
662+
// Wait for all setup to complete
663+
await Promise.all(setupPromises);
664+
const setupTime = Date.now() - totalStart;
665+
666+
const worktreeName = await worktreeNamePromise;
667+
const baseBranch = await baseBranchPromise;
647668
const worktreePath = this.getWorktreePath(worktreeName);
648669
const branchName = `array/${worktreeName}`;
649-
const baseBranch = options?.baseBranch ?? (await this.getDefaultBranch());
650670

651671
this.logger.info("Creating worktree", {
652672
worktreeName,
653673
worktreePath,
654674
branchName,
655675
baseBranch,
656676
external: this.usesExternalPath(),
677+
setupTimeMs: setupTime,
657678
});
658679

659680
// Create the worktree with a new branch
681+
const gitStart = Date.now();
660682
if (this.usesExternalPath()) {
661683
// Use absolute path for external worktrees
662684
await this.runGitCommand(
663-
`worktree add -b "${branchName}" "${worktreePath}" "${baseBranch}"`,
685+
`worktree add --quiet -b "${branchName}" "${worktreePath}" "${baseBranch}"`,
664686
);
665687
} else {
666688
// Use relative path from repo root for in-repo worktrees
667689
const relativePath = `${WORKTREE_FOLDER_NAME}/${worktreeName}`;
668690
await this.runGitCommand(
669-
`worktree add -b "${branchName}" "./${relativePath}" "${baseBranch}"`,
691+
`worktree add --quiet -b "${branchName}" "./${relativePath}" "${baseBranch}"`,
670692
);
671693
}
694+
const gitTime = Date.now() - gitStart;
672695

673696
const createdAt = new Date().toISOString();
674697

675698
this.logger.info("Worktree created successfully", {
676699
worktreeName,
677700
worktreePath,
678701
branchName,
702+
setupTimeMs: setupTime,
703+
gitWorktreeAddMs: gitTime,
704+
totalMs: Date.now() - totalStart,
679705
});
680706

681707
return {

0 commit comments

Comments
 (0)