Skip to content

Commit 2a37dbe

Browse files
committed
fix(copilot): honor aborts before cancellation commit
1 parent 6382f51 commit 2a37dbe

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,50 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
10501050
expect(mockCancelByExecution).not.toHaveBeenCalled()
10511051
})
10521052

1053+
it('rolls back pause staging when cancellation is aborted during staging', async () => {
1054+
const controller = new AbortController()
1055+
mockStagePausedCancellation.mockImplementationOnce(async () => {
1056+
controller.abort()
1057+
return { kind: 'idle' }
1058+
})
1059+
1060+
const response = await cancelWorkflowExecutionPostAuth({
1061+
workflowId: 'wf-1',
1062+
executionId: 'ex-1',
1063+
userId: 'user-1',
1064+
abortSignal: controller.signal,
1065+
})
1066+
1067+
expect(response.status).toBe(409)
1068+
expect(mockClearPausedCancellationIntent).toHaveBeenCalledWith('ex-1', 'wf-1')
1069+
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
1070+
expect(mockAbortManualExecution).not.toHaveBeenCalled()
1071+
expect(mockCancelByExecution).not.toHaveBeenCalled()
1072+
})
1073+
1074+
it('rolls back an active resume staged while cancellation is aborted', async () => {
1075+
const controller = new AbortController()
1076+
mockStagePausedCancellation.mockImplementationOnce(async () => {
1077+
controller.abort()
1078+
return { kind: 'active_resume', target: ACTIVE_RESUME_TARGET }
1079+
})
1080+
1081+
const response = await cancelWorkflowExecutionPostAuth({
1082+
workflowId: 'wf-1',
1083+
executionId: 'ex-1',
1084+
userId: 'user-1',
1085+
abortSignal: controller.signal,
1086+
})
1087+
1088+
expect(response.status).toBe(409)
1089+
expect(mockRollbackActiveResumeCancellation).toHaveBeenCalledWith(
1090+
'ex-1',
1091+
'wf-1',
1092+
'resume-entry-1'
1093+
)
1094+
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
1095+
})
1096+
10531097
it('returns 404 when the execution does not belong to the workflow', async () => {
10541098
dbChainMockFns.limit.mockResolvedValueOnce([])
10551099

apps/sim/lib/execution/cancel-workflow-execution-post-auth.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ function mergeExecutionStopSignal(
111111
summary.signalledExecutionIds.add(signalExecutionId)
112112
}
113113

114+
/**
115+
* Commits cancellation after the caller's final abort check. Once signalling begins, the
116+
* operation must finish reconciliation because workers may already have observed the durable,
117+
* local, or queued signal; attempting to honor a later abort could revive only part of a run.
118+
*/
114119
async function signalExecutionStop(args: {
115120
workflowId: string
116121
signalExecutionId: string
@@ -341,6 +346,28 @@ function cancellationAbortedResponse(abortSignal?: AbortSignal): NextResponse |
341346
: null
342347
}
343348

349+
async function rollbackPausedCancellationAfterAbort(args: {
350+
stage: PausedCancellationStage
351+
workflowId: string
352+
executionId: string
353+
abortSignal?: AbortSignal
354+
}): Promise<NextResponse | null> {
355+
const abortResponse = cancellationAbortedResponse(args.abortSignal)
356+
if (!abortResponse) return null
357+
358+
if (args.stage.kind === 'active_resume') {
359+
await PauseResumeManager.rollbackActiveResumeCancellation(
360+
args.executionId,
361+
args.workflowId,
362+
args.stage.target.resumeEntryId
363+
)
364+
} else if (args.stage.kind === 'idle') {
365+
await PauseResumeManager.clearPausedCancellationIntent(args.executionId, args.workflowId)
366+
}
367+
368+
return abortResponse
369+
}
370+
344371
/** Runs the existing workflow cancellation lifecycle after surface authentication. */
345372
export async function cancelWorkflowExecutionPostAuth({
346373
workflowId,
@@ -593,6 +620,14 @@ export async function cancelWorkflowExecutionPostAuth({
593620
executionId,
594621
workflowId
595622
)
623+
const postStageAbort = await rollbackPausedCancellationAfterAbort({
624+
stage: pausedCancellationStage,
625+
workflowId,
626+
executionId,
627+
abortSignal,
628+
})
629+
if (postStageAbort) return postStageAbort
630+
596631
let effectivePausedCancellationPath = isPausedCancellationStage(pausedCancellationStage)
597632
let activeResumeTarget =
598633
pausedCancellationStage.kind === 'active_resume' ? pausedCancellationStage.target : null
@@ -656,6 +691,17 @@ export async function cancelWorkflowExecutionPostAuth({
656691
executionId,
657692
workflowId
658693
)
694+
const postLateStageAbort = await rollbackPausedCancellationAfterAbort({
695+
stage: pausedCancellationStage,
696+
workflowId,
697+
executionId,
698+
abortSignal,
699+
})
700+
if (postLateStageAbort) {
701+
await clearStopSignalMarkers(stopSummary)
702+
return postLateStageAbort
703+
}
704+
659705
effectivePausedCancellationPath = isPausedCancellationStage(pausedCancellationStage)
660706
activeResumeTarget =
661707
pausedCancellationStage.kind === 'active_resume' ? pausedCancellationStage.target : null

0 commit comments

Comments
 (0)