@@ -11,6 +11,7 @@ import { cloneRepository, createBranch } from './git';
1111import { subscribeToSessionEvents } from './events' ;
1212import {
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+
5771export 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 ( ) ;
0 commit comments