Repository: n8n-io/n8n
Pull request #15147: fix(core): Support task runner in `execute` and `execute-batch` commands
Author: login: ivov
Labels: name: core
description: Enhancement outside /nodes-base and /editor-ui
name: n8n team
description: Authored by the n8n team
name: Released
Created: 2025-05-06T14:16:43Z | Merged: 2025-05-06T15:53:36Z

## Summary

Please see story.

## Related Linear tickets, Github issues, and Community forum posts

https://linear.app/n8n/issue/CAT-728

## Review / Merge checklist

- [x] PR title and summary are descriptive. ([conventions](../blob/master/.github/pull_request_title_conventions.md)) <!--
   **Remember, the title automatically goes into the changelog.
   Use `(no-changelog)` otherwise.**
-->
- [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created.
- [x] Tests included. <!--
   A bug is not considered fixed, unless a test is added to prevent it from happening again.
   A feature is not complete without tests.
-->
- [ ] PR Labeled with `release/backport` (if the PR is an urgent fix that needs to be backported)



Diff:
diff --git a/packages/cli/src/commands/__tests__/execute-batch.test.ts b/packages/cli/src/commands/__tests__/execute-batch.test.ts
new file mode 100644
index 0000000000000..3251a63b55023
--- /dev/null
+++ b/packages/cli/src/commands/__tests__/execute-batch.test.ts
@@ -0,0 +1,94 @@
+import { GlobalConfig } from '@n8n/config';
+import type { User, WorkflowEntity } from '@n8n/db';
+import { Container } from '@n8n/di';
+import type { SelectQueryBuilder } from '@n8n/typeorm';
+import type { Config } from '@oclif/core';
+import { mock } from 'jest-mock-extended';
+import type { IRun } from 'n8n-workflow';
+
+import { ActiveExecutions } from '@/active-executions';
+import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
+import { DeprecationService } from '@/deprecation/deprecation.service';
+import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
+import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
+import { ExternalHooks } from '@/external-hooks';
+import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
+import { PostHogClient } from '@/posthog';
+import { OwnershipService } from '@/services/ownership.service';
+import { ShutdownService } from '@/shutdown/shutdown.service';
+import { TaskRunnerModule } from '@/task-runners/task-runner-module';
+import { WorkflowRunner } from '@/workflow-runner';
+import { mockInstance } from '@test/mocking';
+
+import { ExecuteBatch } from '../execute-batch';
+
+const taskRunnerModule = mockInstance(TaskRunnerModule);
+const workflowRepository = mockInstance(WorkflowRepository);
+const ownershipService = mockInstance(OwnershipService);
+const workflowRunner = mockInstance(WorkflowRunner);
+const activeExecutions = mockInstance(ActiveExecutions);
+const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
+const shutdownService = mockInstance(ShutdownService);
+const deprecationService = mockInstance(DeprecationService);
+mockInstance(MessageEventBus);
+const posthogClient = mockInstance(PostHogClient);
+const telemetryEventRelay = mockInstance(TelemetryEventRelay);
+const externalHooks = mockInstance(ExternalHooks);
+
+jest.mock('@/db', () => ({
+	init: jest.fn().mockResolvedValue(undefined),
+	migrate: jest.fn().mockResolvedValue(undefined),
+	connectionState: { connected: false },
+	close: jest.fn().mockResolvedValue(undefined),
+}));
+
+test('should start a task runner when task runners are enabled', async () => {
+	// arrange
+
+	const workflow = mock<WorkflowEntity>({
+		id: '123',
+		nodes: [{ type: 'n8n-nodes-base.manualTrigger' }],
+	});
+
+	const run = mock<IRun>({ data: { resultData: { error: undefined } } });
+
+	const queryBuilder = mock<SelectQueryBuilder<WorkflowEntity>>({
+		andWhere: jest.fn().mockReturnThis(),
+		getMany: jest.fn().mockResolvedValue([workflow]),
+	});
+
+	loadNodesAndCredentials.init.mockResolvedValue(undefined);
+	shutdownService.shutdown.mockReturnValue();
+	deprecationService.warn.mockReturnValue();
+	posthogClient.init.mockResolvedValue();
+	telemetryEventRelay.init.mockResolvedValue();
+	externalHooks.init.mockResolvedValue();
+
+	workflowRepository.createQueryBuilder.mockReturnValue(queryBuilder);
+	ownershipService.getInstanceOwner.mockResolvedValue(mock<User>({ id: '123' }));
+	workflowRunner.run.mockResolvedValue('123');
+	activeExecutions.getPostExecutePromise.mockResolvedValue(run);
+
+	Container.set(
+		GlobalConfig,
+		mock<GlobalConfig>({
+			taskRunners: { enabled: true },
+			nodes: { communityPackages: { enabled: false } },
+		}),
+	);
+
+	const cmd = new ExecuteBatch([], {} as Config);
+	// @ts-expect-error Private property
+	cmd.parse = jest.fn().mockResolvedValue({ flags: {} });
+	// @ts-expect-error Private property
+	cmd.runTests = jest.fn().mockResolvedValue({ summary: { failedExecutions: [] } });
+
+	// act
+
+	await cmd.init();
+	await cmd.run();
+
+	// assert
+
+	expect(taskRunnerModule.start).toHaveBeenCalledTimes(1);
+});
diff --git a/packages/cli/src/commands/__tests__/execute.test.ts b/packages/cli/src/commands/__tests__/execute.test.ts
new file mode 100644
index 0000000000000..369d5a8176cce
--- /dev/null
+++ b/packages/cli/src/commands/__tests__/execute.test.ts
@@ -0,0 +1,86 @@
+import { GlobalConfig } from '@n8n/config';
+import type { User, WorkflowEntity } from '@n8n/db';
+import { Container } from '@n8n/di';
+import type { Config } from '@oclif/core';
+import { mock } from 'jest-mock-extended';
+import type { IRun } from 'n8n-workflow';
+
+import { ActiveExecutions } from '@/active-executions';
+import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
+import { DeprecationService } from '@/deprecation/deprecation.service';
+import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
+import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
+import { ExternalHooks } from '@/external-hooks';
+import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
+import { PostHogClient } from '@/posthog';
+import { OwnershipService } from '@/services/ownership.service';
+import { ShutdownService } from '@/shutdown/shutdown.service';
+import { TaskRunnerModule } from '@/task-runners/task-runner-module';
+import { WorkflowRunner } from '@/workflow-runner';
+import { mockInstance } from '@test/mocking';
+
+import { Execute } from '../execute';
+
+const taskRunnerModule = mockInstance(TaskRunnerModule);
+const workflowRepository = mockInstance(WorkflowRepository);
+const ownershipService = mockInstance(OwnershipService);
+const workflowRunner = mockInstance(WorkflowRunner);
+const activeExecutions = mockInstance(ActiveExecutions);
+const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
+const shutdownService = mockInstance(ShutdownService);
+const deprecationService = mockInstance(DeprecationService);
+mockInstance(MessageEventBus);
+const posthogClient = mockInstance(PostHogClient);
+const telemetryEventRelay = mockInstance(TelemetryEventRelay);
+const externalHooks = mockInstance(ExternalHooks);
+
+jest.mock('@/db', () => ({
+	init: jest.fn().mockResolvedValue(undefined),
+	migrate: jest.fn().mockResolvedValue(undefined),
+	connectionState: { connected: false },
+	close: jest.fn().mockResolvedValue(undefined),
+}));
+
+test('should start a task runner when task runners are enabled', async () => {
+	// arrange
+
+	const workflow = mock<WorkflowEntity>({
+		id: '123',
+		nodes: [{ type: 'n8n-nodes-base.manualTrigger' }],
+	});
+
+	const run = mock<IRun>({ data: { resultData: { error: undefined } } });
+
+	loadNodesAndCredentials.init.mockResolvedValue(undefined);
+	shutdownService.shutdown.mockReturnValue();
+	deprecationService.warn.mockReturnValue();
+	posthogClient.init.mockResolvedValue();
+	telemetryEventRelay.init.mockResolvedValue();
+	externalHooks.init.mockResolvedValue();
+
+	workflowRepository.findOneBy.mockResolvedValue(workflow);
+	ownershipService.getInstanceOwner.mockResolvedValue(mock<User>({ id: '123' }));
+	workflowRunner.run.mockResolvedValue('123');
+	activeExecutions.getPostExecutePromise.mockResolvedValue(run);
+
+	Container.set(
+		GlobalConfig,
+		mock<GlobalConfig>({
+			taskRunners: { enabled: true },
+			nodes: { communityPackages: { enabled: false } },
+		}),
+	);
+
+	const cmd = new Execute([], {} as Config);
+	// @ts-expect-error Private property
+	cmd.parse = jest.fn().mockResolvedValue({ flags: { id: '123' } });
+
+	// act
+
+	await cmd.init();
+	await cmd.run();
+
+	// assert
+
+	expect(taskRunnerModule.start).toHaveBeenCalledTimes(1);
+});
diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts
index 4e34d9dd4e027..c107ec9019f58 100644
--- a/packages/cli/src/commands/base-command.ts
+++ b/packages/cli/src/commands/base-command.ts
@@ -69,6 +69,9 @@ export abstract class BaseCommand extends Command {
 	/** Whether to init community packages (if enabled) */
 	protected needsCommunityPackages = false;
 
+	/** Whether to init task runner (if enabled). */
+	protected needsTaskRunner = false;
+
 	protected async loadModules() {
 		for (const moduleName of this.modulesConfig.modules) {
 			let preInitModule: ModulePreInit | undefined;
@@ -156,6 +159,11 @@ export abstract class BaseCommand extends Command {
 			await Container.get(CommunityPackagesService).checkForMissingPackages();
 		}
 
+		if (this.needsTaskRunner && this.globalConfig.taskRunners.enabled) {
+			const { TaskRunnerModule } = await import('@/task-runners/task-runner-module');
+			await Container.get(TaskRunnerModule).start();
+		}
+
 		// TODO: remove this after the cyclic dependencies around the event-bus are resolved
 		Container.get(MessageEventBus);
 
diff --git a/packages/cli/src/commands/execute-batch.ts b/packages/cli/src/commands/execute-batch.ts
index 42ff4e8e1296d..b4a5c312830f5 100644
--- a/packages/cli/src/commands/execute-batch.ts
+++ b/packages/cli/src/commands/execute-batch.ts
@@ -112,6 +112,8 @@ export class ExecuteBatch extends BaseCommand {
 
 	override needsCommunityPackages = true;
 
+	override needsTaskRunner = true;
+
 	/**
 	 * Gracefully handles exit.
 	 * @param {boolean} skipExit Whether to skip exit or number according to received signal
@@ -335,7 +337,6 @@ export class ExecuteBatch extends BaseCommand {
 		if (results.summary.failedExecutions > 0) {
 			this.exit(1);
 		}
-		this.exit(0);
 	}
 
 	mergeResults(results: IResult, retryResults: IResult) {
diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts
index e880c30733298..70aa15bf82fa0 100644
--- a/packages/cli/src/commands/execute.ts
+++ b/packages/cli/src/commands/execute.ts
@@ -29,6 +29,8 @@ export class Execute extends BaseCommand {
 
 	override needsCommunityPackages = true;
 
+	override needsTaskRunner = true;
+
 	async init() {
 		await super.init();
 		await this.initBinaryDataService();
diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts
index c7d473de28f40..071a60a8c6e98 100644
--- a/packages/cli/src/commands/start.ts
+++ b/packages/cli/src/commands/start.ts
@@ -68,6 +68,8 @@ export class Start extends BaseCommand {
 
 	override needsCommunityPackages = true;
 
+	override needsTaskRunner = true;
+
 	private getEditorUrl = () => Container.get(UrlService).getInstanceBaseUrl();
 
 	/**
@@ -234,13 +236,6 @@ export class Start extends BaseCommand {
 			await this.generateStaticAssets();
 		}
 
-		const { taskRunners: taskRunnerConfig } = this.globalConfig;
-		if (taskRunnerConfig.enabled) {
-			const { TaskRunnerModule } = await import('@/task-runners/task-runner-module');
-			const taskRunnerModule = Container.get(TaskRunnerModule);
-			await taskRunnerModule.start();
-		}
-
 		await this.loadModules();
 	}
 
diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts
index 989a11a21c858..4a407e1f79539 100644
--- a/packages/cli/src/commands/worker.ts
+++ b/packages/cli/src/commands/worker.ts
@@ -39,6 +39,8 @@ export class Worker extends BaseCommand {
 
 	override needsCommunityPackages = true;
 
+	override needsTaskRunner = true;
+
 	/**
 	 * Stop n8n in a graceful way.
 	 * Make for example sure that all the webhooks from third party services
@@ -108,13 +110,6 @@ export class Worker extends BaseCommand {
 			}),
 		);
 
-		const { taskRunners: taskRunnerConfig } = this.globalConfig;
-		if (taskRunnerConfig.enabled) {
-			const { TaskRunnerModule } = await import('@/task-runners/task-runner-module');
-			const taskRunnerModule = Container.get(TaskRunnerModule);
-			await taskRunnerModule.start();
-		}
-
 		await this.loadModules();
 	}
 


Reviews:
user: login: cubic-dev-ai[bot]
body: mrge found 2 issues across 4 files. View them in [mrge.io](https://mrge.io/pr/n8n-io/n8n/pull/15147)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:19:36Z
user: login: netroy
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:39:24Z
user: login: netroy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T15:13:40Z
user: login: netroy
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T15:36:31Z

Review comments:
diff_hunk: @@ -335,7 +341,7 @@ export class ExecuteBatch extends BaseCommand {
 		if (results.summary.failedExecutions > 0) {
 			this.exit(1);
 		}
-		this.exit(0);
+		if (!inTest) this.exit(0);
path: packages/cli/src/commands/execute-batch.ts
user: login: cubic-dev-ai[bot]
body: <!-- metadata:{"confidence":9,"steps":[{"text":"I'll review the changed lines in the PR for any issues. Let me focus specifically on the lines that start with \"+\" in the diff.\n\nFirst, let me understand what files are available in the repository to get more context.","toolCalls":[{"args":{"pattern":"packages/cli/src/constants.ts"},"toolName":"listFiles"}]},{"text":"Let me check the `inTest` constant that was imported:","toolCalls":[{"args":{"command":"grep -n \"inTest\" packages/cli/src/constants.ts"},"toolName":"executeCommand"}]},{"text":"Now let me check the task runner module that's being imported:","toolCalls":[{"args":{"pattern":"packages/cli/src/task-runners/task-runner-module.ts"},"toolName":"listFiles"}]},{"text":"Let me check the global config structure to understand the `taskRunners` property:","toolCalls":[{"args":{"command":"grep -A 10 \"globalConfig\" packages/cli/src/commands/execute-batch.ts"},"toolName":"executeCommand"}]},{"text":"Let me also check the `exit` method to understand the change in the exit behavior:","toolCalls":[{"args":{"command":"grep -n \"exit\" packages/cli/src/commands/base-command.ts"},"toolName":"executeCommand"}]},{"text":"","toolCalls":[{"args":{"command":"grep -n \"exit(\" packages/cli/src/commands/base-command.ts"},"toolName":"executeCommand"}]}]} -->
Inconsistent exit behavior between test and non-test environments may lead to unexpected side effects.
created_at: 2025-05-06T14:19:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -263,6 +264,11 @@ export class ExecuteBatch extends BaseCommand {
 			ExecuteBatch.githubWorkflow = true;
 		}
 
+		if (this.globalConfig.taskRunners.enabled) {
path: packages/cli/src/commands/execute-batch.ts
user: login: cubic-dev-ai[bot]
body: <!-- metadata:{"confidence":7,"steps":[{"text":"I'll review the changed lines in the PR for any issues. Let me focus specifically on the lines that start with \"+\" in the diff.\n\nFirst, let me understand what files are available in the repository to get more context.","toolCalls":[{"args":{"pattern":"packages/cli/src/constants.ts"},"toolName":"listFiles"}]},{"text":"Let me check the `inTest` constant that was imported:","toolCalls":[{"args":{"command":"grep -n \"inTest\" packages/cli/src/constants.ts"},"toolName":"executeCommand"}]},{"text":"Now let me check the task runner module that's being imported:","toolCalls":[{"args":{"pattern":"packages/cli/src/task-runners/task-runner-module.ts"},"toolName":"listFiles"}]},{"text":"Let me check the global config structure to understand the `taskRunners` property:","toolCalls":[{"args":{"command":"grep -A 10 \"globalConfig\" packages/cli/src/commands/execute-batch.ts"},"toolName":"executeCommand"}]},{"text":"Let me also check the `exit` method to understand the change in the exit behavior:","toolCalls":[{"args":{"command":"grep -n \"exit\" packages/cli/src/commands/base-command.ts"},"toolName":"executeCommand"}]},{"text":"","toolCalls":[{"args":{"command":"grep -n \"exit(\" packages/cli/src/commands/base-command.ts"},"toolName":"executeCommand"}]}]} -->
Missing error handling for the task runner initialization.
created_at: 2025-05-06T14:19:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -263,6 +264,11 @@ export class ExecuteBatch extends BaseCommand {
 			ExecuteBatch.githubWorkflow = true;
 		}
 
+		if (this.globalConfig.taskRunners.enabled) {
path: packages/cli/src/commands/execute-batch.ts
user: login: netroy
body: there are at least 4 commands that do this now. maybe we should create a new flag in the `BaseCommand` similar to `needsCommunityPackages`, instead of duplicating this in 4 places?
created_at: 2025-05-06T14:39:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-05-06T14:22:29Z
body: ## [Codecov](https://app.codecov.io/gh/n8n-io/n8n/pull/15147?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=n8n-io) Report
Attention: Patch coverage is `87.50000%` with `1 line` in your changes missing coverage. Please review.
| [Files with missing lines](https://app.codecov.io/gh/n8n-io/n8n/pull/15147?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=n8n-io) | Patch % | Lines |
|---|---|---|
| [packages/cli/src/commands/start.ts](https://app.codecov.io/gh/n8n-io/n8n/pull/15147?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=n8n-io#diff-cGFja2FnZXMvY2xpL3NyYy9jb21tYW5kcy9zdGFydC50cw==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/n8n-io/n8n/pull/15147?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=n8n-io) |

:loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-06T15:22:47Z
body: :warning: Some Cypress E2E specs are failing, please fix them before merging
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-06T15:46:25Z
body: :white_check_mark: All Cypress E2E specs passed
author_association: CONTRIBUTOR
user: login: janober
created_at: 2025-05-12T16:08:05Z
body: Got released with [`n8n@1.93.0`](https://github.com/n8n-io/n8n/releases/tag/n8n%401.93.0)
author_association: MEMBER