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
15 changes: 15 additions & 0 deletions apps/array/src/main/lib/timing.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(label: string, fn: () => Promise<T>): Promise<T> => {
const start = Date.now();
const result = await fn();
log.info(`[timing] ${label}: ${Date.now() - start}ms`);
return result;
};
}
26 changes: 12 additions & 14 deletions apps/array/src/main/services/workspace/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,23 +299,21 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
}
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}`,
Expand Down
34 changes: 22 additions & 12 deletions apps/array/src/renderer/sagas/task/task-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;

Expand Down
22 changes: 18 additions & 4 deletions apps/array/src/shared/lib/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export abstract class Saga<TInput, TOutput> {
rollback: () => Promise<void>;
}> = [];
private currentStepName = "unknown";
private stepTimings: Array<{ name: string; durationMs: number }> = [];
protected readonly log: SagaLogger;

constructor(logger?: SagaLogger) {
Expand All @@ -62,15 +63,20 @@ export abstract class Saga<TInput, TOutput> {
async run(input: TInput): Promise<SagaResult<TOutput>> {
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 };
Expand Down Expand Up @@ -110,16 +116,19 @@ export abstract class Saga<TInput, TOutput> {
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({
name: config.name,
rollback: () => config.rollback(result),
});

this.log.debug(`Step completed: ${config.name}`);

return result;
}

Expand All @@ -138,8 +147,13 @@ export abstract class Saga<TInput, TOutput> {
): Promise<T> {
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;
}

Expand Down
7 changes: 4 additions & 3 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,6 @@ export class Agent {
): Promise<InProcessAcpConnection> {
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;

Expand All @@ -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) => {
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
88 changes: 57 additions & 31 deletions packages/agent/src/worktree-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,77 +605,103 @@
}

private async getDefaultBranch(): Promise<string> {
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<WorktreeInfo> {
const totalStart = Date.now();

// Run setup tasks in parallel for speed
const setupPromises: Promise<unknown>[] = [];

// 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,
worktreePath,
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();

this.logger.info("Worktree created successfully", {
worktreeName,
worktreePath,
branchName,
setupTimeMs: setupTime,
gitWorktreeAddMs: gitTime,
totalMs: Date.now() - totalStart,
});

return {
Expand Down