Skip to content

Commit 494eb19

Browse files
committed
fix(sidecar): improve execution lifecycle and git handling
1 parent 95b706c commit 494eb19

File tree

4 files changed

+138
-15
lines changed

4 files changed

+138
-15
lines changed

apps/sidecar/src/services/execution/events.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ async function handleOpenCodeEvent(event: { type: string; properties?: Record<st
223223

224224
case 'session.error':
225225
if (taskId) {
226+
const execution = activeExecutions.get(taskId);
227+
if (execution?.cancelled) {
228+
console.log(`[Execution] Ignoring session.error for task ${taskId.slice(0, 8)} (cancelled)`);
229+
break;
230+
}
226231
const rawError = event.properties?.error;
227232
const { message: errorDetail, isAuthError, isRateLimit } = extractCleanError(rawError);
228233
const headline = isAuthError

apps/sidecar/src/services/execution/git.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export async function cloneRepository(
2626
cloneUrl: string,
2727
repoPath: string,
2828
accessToken: string | null,
29-
defaultBranch: string
29+
defaultBranch: string,
30+
signal?: AbortSignal
3031
): Promise<void> {
3132
console.log(`[Execution] Preparing to clone into ${repoPath}`);
3233

@@ -51,14 +52,14 @@ export async function cloneRepository(
5152
: cloneUrl;
5253

5354
console.log(`[Execution] Cloning ${cloneUrl} (branch: ${defaultBranch})...`);
54-
await execAsync(`git clone --depth 1 --branch ${defaultBranch} ${url} ${repoPath}`);
55-
await execAsync(`chmod -R a+rwX ${repoPath}`);
55+
await execAsync(`git clone --depth 1 --branch ${defaultBranch} ${url} ${repoPath}`, { signal });
56+
await execAsync(`chmod -R a+rwX ${repoPath}`, { signal });
5657
console.log(`[Execution] Clone complete`);
5758
}
5859

59-
export async function createBranch(repoPath: string, branchName: string): Promise<void> {
60+
export async function createBranch(repoPath: string, branchName: string, signal?: AbortSignal): Promise<void> {
6061
console.log(`[Execution] Creating branch: ${branchName}`);
61-
await execAsync(`git checkout -B ${branchName}`, { cwd: repoPath });
62+
await execAsync(`git checkout -B ${branchName}`, { cwd: repoPath, signal });
6263
console.log(`[Execution] Branch ready and checked out`);
6364
}
6465

apps/sidecar/src/services/execution/lifecycle.ts

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { cloneRepository, createBranch } from './git';
1111
import { subscribeToSessionEvents } from './events';
1212
import {
1313
activeExecutions,
14+
startingExecutions,
1415
sessionToTask,
1516
broadcastProgress,
1617
addLogEntry,
@@ -54,18 +55,49 @@ function normalizeExecutionStartupError(error: unknown): string {
5455
return message;
5556
}
5657

58+
function isExecutionAbortError(error: unknown): boolean {
59+
if (!(error instanceof Error)) {
60+
return false;
61+
}
62+
63+
const message = error.message.toLowerCase();
64+
return (
65+
error.name === 'AbortError' ||
66+
message.includes('abort') ||
67+
message.includes('operation was aborted')
68+
);
69+
}
70+
5771
export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promise<{ success: boolean; error?: string }> {
58-
if (activeExecutions.has(taskId)) {
72+
if (activeExecutions.has(taskId) || startingExecutions.has(taskId)) {
5973
return { success: false, error: 'Task is already running' };
6074
}
6175

6276
const settings = await prisma.settings.findFirst({ where: { id: 'default' } });
6377
const parallelLimit = settings?.parallelLimit ?? 3;
6478

65-
if (activeExecutions.size >= parallelLimit) {
79+
if (activeExecutions.size + startingExecutions.size >= parallelLimit) {
6680
return { success: false, error: `Parallel limit reached (${parallelLimit} tasks max)` };
6781
}
6882

83+
const branchName = `openlinear/${taskId.slice(0, 8)}`;
84+
const startupAbortController = new AbortController();
85+
const startupState = {
86+
taskId,
87+
branchName,
88+
repoPath: null as string | null,
89+
startedAt: new Date(),
90+
cancelRequested: false,
91+
abortController: startupAbortController,
92+
};
93+
startingExecutions.set(taskId, startupState);
94+
95+
const clearStartupExecution = () => {
96+
if (startingExecutions.get(taskId) === startupState) {
97+
startingExecutions.delete(taskId);
98+
}
99+
};
100+
69101
let accessToken: string | null = null;
70102
let useLocalPath: string | null = null;
71103
let project: { id: string; name: string; fullName: string; cloneUrl: string; defaultBranch: string } | null = null;
@@ -87,6 +119,7 @@ export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promis
87119
});
88120

89121
if (!taskWithProject) {
122+
clearStartupExecution();
90123
return { success: false, error: 'Task not found' };
91124
}
92125

@@ -108,31 +141,40 @@ export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promis
108141
return { success: false, error: 'No active project selected' };
109142
}
110143

111-
const branchName = `openlinear/${taskId.slice(0, 8)}`;
112144
let repoPath: string;
113145

114146
if (useLocalPath) {
115147
repoPath = useLocalPath;
116148
} else if (project) {
117149
repoPath = join(REPOS_DIR, project.name, taskId.slice(0, 8));
118150
} else {
151+
clearStartupExecution();
119152
return { success: false, error: 'No active project selected' };
120153
}
121154

155+
startupState.repoPath = repoPath;
156+
122157
try {
123158
// Step 1: Clone
124159
if (useLocalPath) {
125160
broadcastProgress(taskId, 'cloning', 'Preparing local repository...');
126-
await createBranch(repoPath, branchName);
161+
await createBranch(repoPath, branchName, startupAbortController.signal);
127162
} else if (project) {
128163
broadcastProgress(taskId, 'cloning', 'Cloning repository...');
129-
await cloneRepository(project.cloneUrl, repoPath, accessToken, project.defaultBranch);
130-
await createBranch(repoPath, branchName);
164+
await cloneRepository(project.cloneUrl, repoPath, accessToken, project.defaultBranch, startupAbortController.signal);
165+
await createBranch(repoPath, branchName, startupAbortController.signal);
166+
}
167+
168+
if (startupState.cancelRequested || startupAbortController.signal.aborted) {
169+
clearStartupExecution();
170+
console.log(`[Execution] Startup cancelled for task ${taskId.slice(0, 8)} before session creation`);
171+
return { success: true };
131172
}
132173

133174
broadcastProgress(taskId, 'executing', 'Starting OpenCode agent...');
134175

135176
if (!userId) {
177+
clearStartupExecution();
136178
return { success: false, error: 'userId is required for execution' };
137179
}
138180

@@ -142,14 +184,27 @@ export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promis
142184
body: {
143185
title: taskWithProject.title,
144186
},
187+
signal: startupAbortController.signal,
145188
});
146189

147190
const sessionId = sessionResponse.data?.id;
148191
if (!sessionId) {
149192
console.error(`[Execution] Failed to create session for task ${taskId.slice(0, 8)}`);
193+
clearStartupExecution();
150194
return { success: false, error: 'Failed to create OpenCode session' };
151195
}
152196

197+
if (startupState.cancelRequested || startupAbortController.signal.aborted) {
198+
try {
199+
await client.session.abort({ path: { id: sessionId } });
200+
} catch (abortError) {
201+
console.error(`[Execution] Failed to abort just-created session ${sessionId} for task ${taskId.slice(0, 8)}:`, abortError);
202+
}
203+
clearStartupExecution();
204+
console.log(`[Execution] Startup cancelled for task ${taskId.slice(0, 8)} after session creation`);
205+
return { success: true };
206+
}
207+
153208
console.log(`[Execution] Session ${sessionId} created for task ${taskId.slice(0, 8)}`);
154209

155210
// Set up timeout
@@ -171,16 +226,18 @@ export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promis
171226
status: 'executing',
172227
logs: [],
173228
client,
174-
startedAt: new Date(),
229+
startedAt: startupState.startedAt,
175230
filesChanged: 0,
176231
toolsExecuted: 0,
177232
promptSent: false,
178233
cancelled: false,
234+
promptAbortController: null,
179235
pendingPermissions: [],
180236
};
181237

182238
activeExecutions.set(taskId, executionState);
183239
sessionToTask.set(sessionId, taskId);
240+
clearStartupExecution();
184241
getOrCreateBuffer(taskId, (msg) => addLogEntry(taskId, 'agent', msg));
185242

186243
// Add initial log entries
@@ -231,17 +288,31 @@ export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promis
231288
console.debug(`[Execution] Could not read model config for task ${taskId.slice(0, 8)}:`, err);
232289
}
233290

291+
const promptAbortController = new AbortController();
292+
executionState.promptAbortController = promptAbortController;
293+
234294
client.session.prompt({
235295
path: { id: sessionId },
236296
body: {
237297
parts: [{ type: 'text', text: prompt }],
238298
...(modelOverride ? { model: modelOverride } : {}),
239299
},
300+
signal: promptAbortController.signal,
240301
}).then(() => {
302+
executionState.promptAbortController = null;
303+
if (executionState.cancelled || !activeExecutions.has(taskId)) {
304+
console.log(`[Execution] Prompt resolved after cancellation for task ${taskId.slice(0, 8)}, ignoring`);
305+
return;
306+
}
241307
console.log(`[Execution] Prompt sent to session ${sessionId}`);
242308
executionState.promptSent = true;
243309
addLogEntry(taskId, 'info', 'Task prompt sent to agent');
244310
}).catch(async (err: Error) => {
311+
executionState.promptAbortController = null;
312+
if (executionState.cancelled || promptAbortController.signal.aborted || err.name === 'AbortError') {
313+
console.log(`[Execution] Prompt aborted for task ${taskId.slice(0, 8)}`);
314+
return;
315+
}
245316
console.error(`[Execution] Prompt error for task ${taskId}:`, err);
246317
const msg = err.message || 'Unknown error';
247318
const isAuth = msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('unauthorized') || msg.toLowerCase().includes('401');
@@ -258,6 +329,11 @@ export async function executeTask({ taskId, userId }: ExecuteTaskParams): Promis
258329
console.log(`[Execution] Started for task ${taskId} in ${repoPath}`);
259330
return { success: true };
260331
} catch (error) {
332+
clearStartupExecution();
333+
if (startupState.cancelRequested || startupAbortController.signal.aborted || isExecutionAbortError(error)) {
334+
console.log(`[Execution] Startup cancelled for task ${taskId.slice(0, 8)}`);
335+
return { success: true };
336+
}
261337
const normalizedError = normalizeExecutionStartupError(error);
262338
console.error(`[Execution] Failed to execute task ${taskId}:`, error);
263339
broadcastProgress(taskId, 'error', normalizedError);
@@ -269,10 +345,40 @@ export async function cancelTask(taskId: string): Promise<{ success: boolean; er
269345
const execution = activeExecutions.get(taskId);
270346

271347
if (!execution) {
272-
return { success: false, error: 'Task is not running' };
348+
const startupExecution = startingExecutions.get(taskId);
349+
350+
if (!startupExecution) {
351+
return { success: false, error: 'Task is not running' };
352+
}
353+
354+
if (startupExecution.cancelRequested) {
355+
return { success: true };
356+
}
357+
358+
startupExecution.cancelRequested = true;
359+
startupExecution.abortController.abort();
360+
361+
const now = new Date();
362+
const elapsedMs = now.getTime() - startupExecution.startedAt.getTime();
363+
364+
broadcastProgress(taskId, 'cancelled', 'Execution cancelled', {
365+
elapsedMs,
366+
estimatedProgress: 0,
367+
});
368+
369+
await updateTaskStatus(taskId, 'cancelled', null, {
370+
executionStartedAt: startupExecution.startedAt,
371+
executionPausedAt: now,
372+
executionElapsedMs: elapsedMs,
373+
executionProgress: 0,
374+
});
375+
376+
return { success: true };
273377
}
274378

275379
execution.cancelled = true;
380+
execution.promptAbortController?.abort();
381+
execution.promptAbortController = null;
276382

277383
const now = new Date();
278384
const elapsedMs = now.getTime() - execution.startedAt.getTime();

apps/sidecar/src/services/execution/state.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,19 @@ export interface ExecutionState {
4040
toolsExecuted: number;
4141
promptSent: boolean;
4242
cancelled: boolean;
43+
promptAbortController: AbortController | null;
4344
pendingPermissions: PendingPermission[];
4445
}
4546

47+
export interface StartupExecutionState {
48+
taskId: string;
49+
branchName: string;
50+
repoPath: string | null;
51+
startedAt: Date;
52+
cancelRequested: boolean;
53+
abortController: AbortController;
54+
}
55+
4656
export interface ExecutionLogEntry {
4757
timestamp: string;
4858
type: 'info' | 'agent' | 'tool' | 'error' | 'success';
@@ -70,14 +80,15 @@ export interface PendingPermission {
7080
}
7181

7282
export const activeExecutions = new Map<string, ExecutionState>();
83+
export const startingExecutions = new Map<string, StartupExecutionState>();
7384
export const sessionToTask = new Map<string, string>();
7485

7586
export function getRunningTaskCount(): number {
76-
return activeExecutions.size;
87+
return activeExecutions.size + startingExecutions.size;
7788
}
7889

7990
export function isTaskRunning(taskId: string): boolean {
80-
return activeExecutions.has(taskId);
91+
return activeExecutions.has(taskId) || startingExecutions.has(taskId);
8192
}
8293

8394
export function getExecutionStatus(taskId: string): ExecutionState | undefined {

0 commit comments

Comments
 (0)