diff --git a/automatic_updates.install b/automatic_updates.install
index 2a0c7900c9d1d4afc72742365bce9f6bf5a01e35..8e926e1551e8ba4158d87fc59ca6d308d610a1d7 100644
--- a/automatic_updates.install
+++ b/automatic_updates.install
@@ -38,6 +38,21 @@ function automatic_updates_requirements($phase) {
       ];
     }
 
+    /** @var \Drupal\automatic_updates\CronUpdateStage $cron_update_stage */
+    $cron_update_stage = \Drupal::service('automatic_updates.cron_update_stage');
+    if ($cron_update_stage->getMode() !== CronUpdateStage::DISABLED) {
+      try {
+        $cron_update_stage->getCommandPath();
+      }
+      catch (\Throwable) {
+        $requirements['automatic_updates_drush_missing'] = [
+          'title' => t('Automatic Updates Drush Requirement'),
+          'severity' => SystemManager::REQUIREMENT_ERROR,
+          'value' => t('Drush is required for unattended updates.'),
+        ];
+      }
+    }
+
     return $requirements;
   }
 }
diff --git a/automatic_updates.module b/automatic_updates.module
index cd415bc1d71851cb8d124978064a5195f5bd2773..a361d3a2c76e85dd0109a3c58836ef042d6aec7e 100644
--- a/automatic_updates.module
+++ b/automatic_updates.module
@@ -9,7 +9,6 @@ declare(strict_types = 1);
 
 use Drupal\automatic_updates\BatchProcessor;
 use Drupal\automatic_updates\CronUpdateStage;
-use Drupal\automatic_updates\Validator\AutomatedCronDisabledValidator;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\automatic_updates\Validation\AdminStatusCheckMessages;
@@ -34,11 +33,6 @@ function automatic_updates_help($route_name, RouteMatchInterface $route_match) {
       $output .= '<p>' . t('Additionally, Automatic Updates periodically runs checks to ensure that updates can be installed, and will warn site administrators if problems are detected.') . '</p>';
       $output .= '<h3>' . t('Requirements') . '</h3>';
       $output .= '<p>' . t('Automatic Updates requires a Composer executable whose version satisfies <code>@version</code>, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerInspector::SUPPORTED_VERSION]) . '</p>';
-      $output .= '<p id="cron-alternate-port">' . t('If your site is running on the built-in PHP web server, unattended (i.e., cron) updates may not work without one of the following workarounds:') . '</p>';
-      $output .= '<ul>';
-      $output .= '<li>' . t('Use a multithreaded web server, such as Apache, NGINX, or on Windows, IIS.') . '</li>';
-      $output .= '<li>' . t('Run another instance of the built-in PHP web server on a different port and configure automatic updates accordingly: <code>$config["automatic_updates.settings"]["cron_port"] = $alternate_port_number;</code>') . '</li>';
-      $output .= '</ul>';
       $output .= '<p>' . t('For more information, see the <a href=":automatic-updates-documentation">online documentation for the Automatic Updates module</a>.', [':automatic-updates-documentation' => 'https://www.drupal.org/docs/8/update/automatic-updates']) . '</p>';
       $output .= '<h3 id="minor-update">' . t('Updating to another minor version of Drupal') . '</h3>';
       $output .= '<p>';
@@ -154,55 +148,6 @@ function automatic_updates_module_implements_alter(&$implementations, $hook) {
     // own routes to avoid these messages while an update is in progress.
     unset($implementations['update']);
   }
-  if ($hook === 'cron') {
-    $hook = $implementations['automatic_updates'];
-    unset($implementations['automatic_updates']);
-    $implementations['automatic_updates'] = $hook;
-  }
-}
-
-/**
- * Implements hook_cron().
- */
-function automatic_updates_cron() {
-  // @todo Refactor this after https://www.drupal.org/project/drupal/issues/2538292
-  // @todo Remove this after https://www.drupal.org/project/drupal/issues/3318964
-  // We don't want to run status checks if we're on the command line, because
-  // if unattended updates are configured to run via the web, running status
-  // checks on the command line could cause invalid results to get cached.
-  if (defined('MAINTENANCE_MODE') || stripos($_SERVER['PHP_SELF'], 'update.php') !== FALSE || CronUpdateStage::isCommandLine()) {
-    return;
-  }
-
-  /** @var \Drupal\automatic_updates\Validator\AutomatedCronDisabledValidator $automated_cron_validator */
-  $automated_cron_validator = \Drupal::service(AutomatedCronDisabledValidator::class);
-  if ($automated_cron_validator->hasTerminateBeenCalled()) {
-    // Running updates and status checks will not work during kernel
-    // termination.
-    \Drupal::logger('automatic_updates')
-      ->info('Unattended automatic updates were triggered by Automated Cron, which is not supported. No update was performed. See the <a href=":url">status report</a> for more information.', [
-        ':url' => Url::fromRoute('system.status')->toString(),
-      ]);
-    return;
-  }
-
-  /** @var \Drupal\automatic_updates\Validation\StatusChecker $status_checker */
-  $status_checker = \Drupal::service('automatic_updates.status_checker');
-  $last_results = $status_checker->getResults();
-  $last_run_time = $status_checker->getLastRunTime();
-  // Do not run status checks more than once an hour unless there are no results
-  // available.
-  if ($last_results === NULL || !$last_run_time || \Drupal::time()->getRequestTime() - $last_run_time > 3600) {
-    $status_checker->run();
-  }
-
-  // Only try to send failure notifications if unattended updates are enabled.
-  /** @var \Drupal\automatic_updates\CronUpdateStage $stage */
-  $stage = \Drupal::service('automatic_updates.cron_update_stage');
-  if ($stage->getMode() !== CronUpdateStage::DISABLED) {
-    \Drupal::service('automatic_updates.status_check_mailer')
-      ->sendFailureNotifications($last_results, $status_checker->getResults());
-  }
 }
 
 /**
@@ -271,7 +216,7 @@ function automatic_updates_form_update_settings_alter(array &$form): void {
     '#type' => 'radios',
     '#title' => t('How unattended updates should be run'),
     '#options' => [
-      'web' => t('By a request to /system/cron'),
+      'web' => t('By using the Automated Cron module or a request to /system/cron'),
       'console' => t('By the <code>auto-update</code> Drush command'),
     ],
     '#default_value' => $config->get('unattended.method'),
@@ -284,6 +229,18 @@ function automatic_updates_form_update_settings_alter(array &$form): void {
     ],
     '#description' => t('To use the <code>/system/cron</code> method <a href="http://drupal.org/docs/user_guide/en/security-cron.html">ensure cron is set up correctly</a>.'),
   ];
+  // @todo Remove check in https://drupal.org/i/3360485.
+  try {
+    /** @var \Drupal\automatic_updates\CronUpdateStage $cron_update_stage */
+    $cron_update_stage = \Drupal::service('automatic_updates.cron_update_stage');
+    $cron_update_stage->getCommandPath();
+  }
+  catch (\Throwable) {
+    \Drupal::messenger()->addWarning('Drush is required for unattended updates.');
+    $form['unattended_level']['#disabled'] = TRUE;
+    $form['unattended_level']['#default_value'] = CronUpdateStage::DISABLED;
+    $form['unattended_method']['#disabled'] = TRUE;
+  }
   $form['#submit'][] = '_automatic_updates_submit_update_settings';
 }
 
diff --git a/automatic_updates.routing.yml b/automatic_updates.routing.yml
index 871f7fd19f66aadfd88da02ef0e82a1e20f0ea19..c0dba2a45ee075c71e1c79ea2906816c1dcbb0f7 100644
--- a/automatic_updates.routing.yml
+++ b/automatic_updates.routing.yml
@@ -27,11 +27,3 @@ automatic_updates.finish:
   options:
     _maintenance_access: TRUE
     _automatic_updates_status_messages: skip
-automatic_updates.cron.post_apply:
-  path: '/automatic-update/cron/post-apply/{stage_id}/{installed_version}/{target_version}/{key}'
-  defaults:
-    _controller: 'automatic_updates.cron_update_stage:handlePostApply'
-  requirements:
-    _custom_access: 'automatic_updates.cron_update_stage:postApplyAccess'
-  options:
-    _maintenance_access: TRUE
diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index ab80d427dd2762b8b969a23cd3c9f5510f1128d7..cd396172d15ca73ae5bd059bf6f3e94fa6896f0f 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -25,11 +25,10 @@ services:
   Drupal\automatic_updates\UpdateStage: '@automatic_updates.update_stage'
   automatic_updates.cron_update_stage:
     class: Drupal\automatic_updates\CronUpdateStage
-    arguments:
-      $committer: '@Drupal\automatic_updates\MaintenanceModeAwareCommitter'
-      $inner: '@automatic_updates.cron_update_stage.inner'
     calls:
       - ['setLogger', ['@logger.channel.automatic_updates']]
+    arguments:
+      $inner: '@automatic_updates.cron_update_stage.inner'
     decorates: 'cron'
   Drupal\automatic_updates\CronUpdateStage: '@automatic_updates.cron_update_stage'
   automatic_updates.requested_update_validator:
@@ -52,9 +51,6 @@ services:
       $lock: '@lock'
     tags:
       - { name: event_subscriber }
-  Drupal\automatic_updates\Validator\AutomatedCronDisabledValidator:
-    tags:
-      - { name: event_subscriber }
   automatic_updates.validator.staged_database_updates:
     class: Drupal\automatic_updates\Validator\StagedDatabaseUpdateValidator
     tags:
@@ -64,13 +60,15 @@ services:
     tags:
       - { name: event_subscriber }
   Drupal\automatic_updates\Validator\VersionPolicyValidator: '@automatic_updates.validator.version_policy'
-  automatic_updates.validator.cron_server:
-    class: Drupal\automatic_updates\Validator\CronServerValidator
-    tags:
-      - { name: event_subscriber }
   logger.channel.automatic_updates:
     parent: logger.channel_base
     arguments: ['automatic_updates']
+  Drupal\automatic_updates\DrushUpdateStage:
+    arguments:
+      $lock: '@lock'
+      $committer: '@Drupal\automatic_updates\MaintenanceModeAwareCommitter'
+    calls:
+      - ['setLogger', ['@logger.channel.automatic_updates']]
   Drupal\automatic_updates\MaintenanceModeAwareCommitter:
     tags:
       - { name: event_subscriber }
diff --git a/drush.services.yml b/drush.services.yml
index 8119ead091b74c37d021c046ad5d16b7487721d2..ff72efa89c8d1db9225139ebc309b02839258f69 100644
--- a/drush.services.yml
+++ b/drush.services.yml
@@ -4,6 +4,3 @@ services:
   Drupal\automatic_updates\Commands\AutomaticUpdatesCommands:
     tags:
       - { name: drush.command }
-  Drupal\automatic_updates\DrushUpdateStage:
-    calls:
-      - ['setLogger', ['@logger.channel.automatic_updates']]
diff --git a/package_manager/tests/src/Build/TemplateProjectTestBase.php b/package_manager/tests/src/Build/TemplateProjectTestBase.php
index a5796540f94d90dff62dd21a0fa23d21feb2055a..a2f034a647ce04eb88147403a2e085ce259b2bfb 100644
--- a/package_manager/tests/src/Build/TemplateProjectTestBase.php
+++ b/package_manager/tests/src/Build/TemplateProjectTestBase.php
@@ -355,7 +355,6 @@ END;
     $port = $this->findAvailablePort();
     $this->metadataServer = $this->instantiateServer($port);
     $code = <<<END
-\$config['automatic_updates.settings']['cron_port'] = $port;
 \$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history';
 END;
     $this->writeSettings($code);
@@ -569,12 +568,15 @@ END;
    *   that once if they will be fired multiple times. If there are no events
    *   specified all life cycle events from PreCreateEvent to PostDestroyEvent
    *   will be asserted.
+   * @param int $wait
+   *   (optional) How many seconds to wait for the events to be fired. Defaults
+   *   to 0.
    * @param string $message
    *   (optional) A message to display with the assertion.
    *
    * @see \Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber::logEventInfo
    */
-  protected function assertExpectedStageEventsFired(string $expected_stage_class, ?array $expected_events = NULL, string $message = ''): void {
+  protected function assertExpectedStageEventsFired(string $expected_stage_class, ?array $expected_events = NULL, int $wait = 0, string $message = ''): void {
     if ($expected_events === NULL) {
       $expected_events = EventLogSubscriber::getSubscribedEvents();
       // The event subscriber uses this event to ensure the log file is excluded
@@ -583,17 +585,28 @@ END;
       unset($expected_events[CollectPathsToExcludeEvent::class]);
       $expected_events = array_keys($expected_events);
     }
+    $this->assertNotEmpty($expected_events);
 
     $log_file = $this->getWorkspaceDirectory() . '/project/' . EventLogSubscriber::LOG_FILE_NAME;
-    $this->assertFileIsReadable($log_file);
-    $log_data = file_get_contents($log_file);
-    $log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR);
+    $max_wait = time() + $wait;
+    do {
+      $this->assertFileIsReadable($log_file);
+      $log_data = file_get_contents($log_file);
+      $log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR);
+
+      // Filter out events logged by any other stage.
+      $log_data = array_filter($log_data, fn (array $event): bool => $event['stage'] === $expected_stage_class);
+
+      // If we've logged at least the expected number of events, stop waiting.
+      // Break out of the loop and assert the expected events were logged.
+      if (count($log_data) >= count($expected_events)) {
+        break;
+      }
+      // Wait a bit before checking again.
+      sleep(5);
+    } while ($max_wait > time());
 
     $this->assertSame($expected_events, array_column($log_data, 'event'), $message);
-
-    // Ensure all the events were fired by the stage we expected.
-    $actual_stages_used = array_unique(array_column($log_data, 'stage'));
-    $this->assertSame([$expected_stage_class], $actual_stages_used);
   }
 
   /**
diff --git a/src/Commands/AutomaticUpdatesCommands.php b/src/Commands/AutomaticUpdatesCommands.php
index 4646cde7724656c8192262a73f810e04c78029e5..4a3b33d7a678baa1b7f36ef361506729609ded56 100644
--- a/src/Commands/AutomaticUpdatesCommands.php
+++ b/src/Commands/AutomaticUpdatesCommands.php
@@ -4,9 +4,11 @@ declare(strict_types = 1);
 
 namespace Drupal\automatic_updates\Commands;
 
+use Drupal\automatic_updates\CronUpdateStage;
 use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\automatic_updates\StatusCheckMailer;
 use Drupal\automatic_updates\Validation\StatusChecker;
+use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drush\Commands\DrushCommands;
 
@@ -17,12 +19,17 @@ use Drush\Commands\DrushCommands;
  *   This is an internal part of Automatic Updates and may be changed or removed
  *   at any time without warning. It should not be called directly, and external
  *   code should not interact with it.
+ *
+ * @todo Remove this class when switching to a Symfony Console command in
+ *   https://drupal.org/i/3360485.
  */
 final class AutomaticUpdatesCommands extends DrushCommands {
 
   /**
    * Constructs a AutomaticUpdatesCommands object.
    *
+   * @param \Drupal\automatic_updates\CronUpdateStage $cronUpdateRunner
+   *   The cron update runner service.
    * @param \Drupal\automatic_updates\DrushUpdateStage $stage
    *   The console cron updater service.
    * @param \Drupal\automatic_updates\Validation\StatusChecker $statusChecker
@@ -31,12 +38,16 @@ final class AutomaticUpdatesCommands extends DrushCommands {
    *   The status check mailer service.
    * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
    *   The config factory service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
    */
   public function __construct(
+    private readonly CronUpdateStage $cronUpdateRunner,
     private readonly DrushUpdateStage $stage,
     private readonly StatusChecker $statusChecker,
     private readonly StatusCheckMailer $statusCheckMailer,
     private readonly ConfigFactoryInterface $configFactory,
+    private readonly TimeInterface $time,
   ) {
     parent::__construct();
   }
@@ -51,6 +62,7 @@ final class AutomaticUpdatesCommands extends DrushCommands {
    * @option $stage-id Internal use only.
    * @option $from-version Internal use only.
    * @option $to-version Internal use only.
+   * @option $is-from-web Internal use only.
    *
    * @command auto-update
    *
@@ -58,7 +70,7 @@ final class AutomaticUpdatesCommands extends DrushCommands {
    *   If the --post-apply option is provided without the --stage-id,
    *   --from-version, and --to-version options.
    */
-  public function autoUpdate(array $options = ['post-apply' => FALSE, 'stage-id' => NULL, 'from-version' => NULL, 'to-version' => NULL]) {
+  public function autoUpdate(array $options = ['post-apply' => FALSE, 'stage-id' => NULL, 'from-version' => NULL, 'to-version' => NULL, 'is-from-web' => FALSE]) {
     $io = $this->io();
 
     // The second half of the update process (post-apply etc.) is done by this
@@ -74,39 +86,53 @@ final class AutomaticUpdatesCommands extends DrushCommands {
 
       $io->info('Running post-apply tasks and final clean-up...');
       $this->stage->handlePostApply($options['stage-id'], $options['from-version'], $options['to-version']);
-      $this->runStatusChecks();
+      $this->runStatusChecks($options['is-from-web']);
     }
     else {
-      if ($this->stage->getMode() === DrushUpdateStage::DISABLED) {
-        $io->error('Automatic updates are disabled.');
-        return;
+      if ($this->cronUpdateRunner->getMode() !== CronUpdateStage::DISABLED) {
+        $release = $this->stage->getTargetRelease();
+        if ($release) {
+          $message = sprintf('Updating Drupal core to %s. This may take a while.', $release->getVersion());
+          $io->info($message);
+          $this->stage->performUpdate($options['is-from-web']);
+          return;
+        }
+        else {
+          $io->info("There is no Drupal core update available.");
+        }
       }
 
-      $release = $this->stage->getTargetRelease();
-      if ($release) {
-        $message = sprintf('Updating Drupal core to %s. This may take a while.', $release->getVersion());
-        $io->info($message);
-        $this->stage->performUpdate($release->getVersion(), 300);
-      }
-      else {
-        $io->info("There is no Drupal core update available.");
-        $this->runStatusChecks();
-      }
+      $this->runStatusChecks($options['is-from-web']);
     }
   }
 
   /**
    * Runs status checks, and sends failure notifications if necessary.
+   *
+   * @param bool $is_from_web
+   *   Whether the current process was started from a web request. To prevent
+   *   misleading or inaccurate results, it's very important that status checks
+   *   are run as the web server user if $is_from_web is TRUE.
    */
-  private function runStatusChecks(): void {
+  private function runStatusChecks(bool $is_from_web): void {
     $method = $this->configFactory->get('automatic_updates.settings')
       ->get('unattended.method');
 
+    $last_results = $this->statusChecker->getResults();
+    $last_run_time = $this->statusChecker->getLastRunTime();
+    // Do not run status checks more than once an hour unless there are no results
+    // available.
+    $needs_run = $last_results === NULL || !$last_run_time || $this->time->getRequestTime() - $last_run_time > 3600;
+
     // To ensure consistent results, only run the status checks if we're
     // explicitly configured to do unattended updates on the command line.
-    if ($method === 'console') {
-      $last_results = $this->statusChecker->getResults();
-      $this->statusCheckMailer->sendFailureNotifications($last_results, $this->statusChecker->run()->getResults());
+    if ($needs_run && (($method === 'web' && $is_from_web) || $method === 'console')) {
+      $this->statusChecker->run();
+      // Only try to send failure notifications if unattended updates are
+      // enabled.
+      if ($this->cronUpdateRunner->getMode() !== CronUpdateStage::DISABLED) {
+        $this->statusCheckMailer->sendFailureNotifications($last_results, $this->statusChecker->getResults());
+      }
     }
   }
 
diff --git a/src/CronUpdateStage.php b/src/CronUpdateStage.php
index b608da7d19daa5874cc7d41e5735cda5d2dd3c72..dde25962269c8ebfb2ebd8893de142bc60bc4d67 100644
--- a/src/CronUpdateStage.php
+++ b/src/CronUpdateStage.php
@@ -5,33 +5,27 @@ declare(strict_types = 1);
 namespace Drupal\automatic_updates;
 
 use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\CronInterface;
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\Core\Mail\MailManagerInterface;
-use Drupal\Core\State\StateInterface;
-use Drupal\Core\TempStore\SharedTempStoreFactory;
-use Drupal\Core\Url;
-use Drupal\package_manager\ComposerInspector;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Exception\ApplyFailedException;
-use Drupal\package_manager\Exception\StageEventException;
-use Drupal\package_manager\Exception\StageFailureMarkerException;
-use Drupal\package_manager\FailureMarker;
+use Drupal\Core\Utility\Error;
 use Drupal\package_manager\PathLocator;
-use Drupal\package_manager\ProjectInfo;
-use Drupal\update\ProjectRelease;
-use GuzzleHttp\Psr7\Uri as GuzzleUri;
-use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
-use PhpTuf\ComposerStager\API\Core\CommitterInterface;
-use PhpTuf\ComposerStager\API\Core\StagerInterface;
-use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
-use Symfony\Component\HttpFoundation\Response;
-use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\NullLogger;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
 
 /**
- * Defines a service that updates via cron.
+ * Runs updates as a detached background process after regular cron tasks.
+ *
+ * The update process will be started in a detached process which will continue
+ * running after the web request has terminated. This is done after the
+ * decorated cron service has been called, so regular cron tasks will always be
+ * run regardless of whether there is an update available and whether an update
+ * is successful.
+ *
+ * @todo Rename this class to CronUpdateRunner because it is no longer a stage
+ *   in https://drupal.org/i/3375940.
  *
  * @internal
  *   This class implements logic specific to Automatic Updates' cron hook
@@ -39,7 +33,9 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  *   It should not be called directly, and external code should not interact
  *   with it.
  */
-class CronUpdateStage extends UpdateStage implements CronInterface {
+class CronUpdateStage implements CronInterface, LoggerAwareInterface {
+
+  use LoggerAwareTrait;
 
   /**
    * The current interface between PHP and the server.
@@ -72,350 +68,100 @@ class CronUpdateStage extends UpdateStage implements CronInterface {
   /**
    * Constructs a CronUpdateStage object.
    *
-   * @param \Drupal\automatic_updates\ReleaseChooser $releaseChooser
-   *   The cron release chooser service.
-   * @param \Drupal\Core\Mail\MailManagerInterface $mailManager
-   *   The mail manager service.
-   * @param \Drupal\automatic_updates\StatusCheckMailer $statusCheckMailer
-   *   The status check mailer service.
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state service.
    * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
    *   The config factory service.
-   * @param \Drupal\package_manager\ComposerInspector $composerInspector
-   *   The Composer inspector service.
    * @param \Drupal\package_manager\PathLocator $pathLocator
    *   The path locator service.
-   * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner
-   *   The beginner service.
-   * @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager
-   *   The stager service.
-   * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer
-   *   The committer service.
-   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
-   *   The file system service.
-   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
-   *   The event dispatcher service.
-   * @param \Drupal\Core\TempStore\SharedTempStoreFactory $tempStoreFactory
-   *   The shared tempstore factory.
-   * @param \Drupal\Component\Datetime\TimeInterface $time
-   *   The time service.
-   * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory
-   *   The path factory service.
-   * @param \Drupal\package_manager\FailureMarker $failureMarker
-   *   The failure marker service.
    * @param \Drupal\Core\CronInterface $inner
    *   The decorated cron service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
    */
   public function __construct(
-    private readonly ReleaseChooser $releaseChooser,
-    private readonly MailManagerInterface $mailManager,
-    private readonly StatusCheckMailer $statusCheckMailer,
-    private readonly StateInterface $state,
     private readonly ConfigFactoryInterface $configFactory,
-    ComposerInspector $composerInspector,
-    PathLocator $pathLocator,
-    BeginnerInterface $beginner,
-    StagerInterface $stager,
-    CommitterInterface $committer,
-    FileSystemInterface $fileSystem,
-    EventDispatcherInterface $eventDispatcher,
-    SharedTempStoreFactory $tempStoreFactory,
-    TimeInterface $time,
-    PathFactoryInterface $pathFactory,
-    FailureMarker $failureMarker,
-    private readonly CronInterface $inner
+    private readonly PathLocator $pathLocator,
+    private readonly CronInterface $inner,
+    private readonly TimeInterface $time,
   ) {
-    parent::__construct($composerInspector, $pathLocator, $beginner, $stager, $committer, $fileSystem, $eventDispatcher, $tempStoreFactory, $time, $pathFactory, $failureMarker);
-  }
-
-  /**
-   * Indicates if we are currently running at the command line.
-   *
-   * @return bool
-   *   TRUE if we are running at the command line, otherwise FALSE.
-   */
-  final public static function isCommandLine(): bool {
-    return self::$serverApi === 'cli';
-  }
-
-  /**
-   * Handles updates during cron.
-   *
-   * @param int|null $timeout
-   *   (optional) How long to allow the file copying operation to run before
-   *   timing out, in seconds, or NULL to never time out. Defaults to 300
-   *   seconds.
-   *
-   * @return bool
-   *   If an update was attempted.
-   */
-  public function handleCron(?int $timeout = 300): bool {
-    if ($this->getMode() === static::DISABLED) {
-      return FALSE;
-    }
-
-    $next_release = $this->getTargetRelease();
-    if ($next_release) {
-      return $this->performUpdate($next_release->getVersion(), $timeout);
-    }
-    return FALSE;
+    $this->setLogger(new NullLogger());
   }
 
   /**
-   * Returns the release of Drupal core to update to, if any.
-   *
-   * @return \Drupal\update\ProjectRelease|null
-   *   The release of Drupal core to which we will update, or NULL if there is
-   *   nothing to update to.
-   */
-  public function getTargetRelease(): ?ProjectRelease {
-    return $this->releaseChooser->getLatestInInstalledMinor($this);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  final public function begin(array $project_versions, ?int $timeout = 300): never {
-    // Unattended updates should never be started using this method. They should
-    // only be done by ::handleCron(), which has a strong opinion about which
-    // release to update to. Throwing an exception here is just to enforce this
-    // boundary. To update to a specific version of core, use
-    // \Drupal\automatic_updates\UpdateStage::begin() (which is called in
-    // ::performUpdate() to start the update to the target version of core
-    // chosen by ::handleCron()).
-    throw new \BadMethodCallException(__METHOD__ . '() cannot be called directly.');
-  }
-
-  /**
-   * Performs the update.
-   *
-   * @param string $target_version
-   *   The target version of Drupal core.
-   * @param int|null $timeout
-   *   How long to allow the operation to run before timing out, in seconds, or
-   *   NULL to never time out.
-   *
-   * @return bool
-   *   Returns TRUE if any update was attempted, otherwise FALSE.
+   * Runs the terminal update command.
    */
-  protected function performUpdate(string $target_version, ?int $timeout): bool {
-    $project_info = new ProjectInfo('drupal');
-    $update_started = FALSE;
-
-    if (!$this->isAvailable()) {
-      if ($project_info->isInstalledVersionSafe() && !$this->isApplying()) {
-        $this->logger->notice('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.');
-        return $update_started;
-      }
-      if (!$project_info->isInstalledVersionSafe() && $this->isApplying()) {
-        $this->logger->notice(
-          'Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href="%url">update form</a>.',
-          ['%url' => Url::fromRoute('update.report_update')->setAbsolute()->toString()],
-        );
-        return $update_started;
-      }
-    }
-
-    // Delete the existing staging area if not available and the site is
-    // currently on an insecure version.
-    if (!$project_info->isInstalledVersionSafe() && !$this->isAvailable() && !$this->isApplying()) {
-      $destroy_message = $this->t('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.');
-      $this->destroy(TRUE, $destroy_message);
-      $this->logger->notice($destroy_message->getUntranslatedString());
-    }
+  protected function runTerminalUpdateCommand(): void {
+    $command_path = $this->getCommandPath();
+    $php_binary_finder = new PhpExecutableFinder();
+    // @todo Check if on Windows to not allow cron updates in
+    //   https://drupal.org/i/3377237.
+    // Use the `&` on the command line to detach this process after it is
+    // started. This will allow the command to outlive the web request.
+    $process = Process::fromShellCommandline($php_binary_finder->find() . " $command_path auto-update --is-from-web &")
+      ->setWorkingDirectory($this->pathLocator->getProjectRoot())
+      ->setTimeout(0);
 
-    $installed_version = $project_info->getInstalledVersion();
-    if (empty($installed_version)) {
-      $this->logger->error('Unable to determine the current version of Drupal core.');
-      return $update_started;
-    }
-
-    // Do the bulk of the update in its own try-catch structure, so that we can
-    // handle any exceptions or validation errors consistently, and destroy the
-    // stage regardless of whether the update succeeds.
     try {
-      $update_started = TRUE;
-      // @see ::begin()
-      $stage_id = parent::begin(['drupal' => $target_version], $timeout);
-      $this->stage();
-      $this->apply();
-    }
-    catch (\Throwable $e) {
-      if ($e instanceof StageEventException && $e->event instanceof PreCreateEvent) {
-        // If the error happened during PreCreateEvent then the update did not
-        // really start.
-        $update_started = FALSE;
-      }
-      // Send notifications about the failed update.
-      $mail_params = [
-        'previous_version' => $installed_version,
-        'target_version' => $target_version,
-        'error_message' => $e->getMessage(),
-      ];
-      // Omit the backtrace in e-mails. That will be visible on the site, and is
-      // also stored in the failure marker.
-      if ($e instanceof StageFailureMarkerException || $e instanceof ApplyFailedException) {
-        $mail_params['error_message'] = $this->failureMarker->getMessage(FALSE);
-      }
-      if ($e instanceof ApplyFailedException) {
-        $mail_params['urgent'] = TRUE;
-        $key = 'cron_failed_apply';
-      }
-      elseif (!$project_info->isInstalledVersionSafe()) {
-        $mail_params['urgent'] = TRUE;
-        $key = 'cron_failed_insecure';
+      $process->start();
+      // Wait for the process to have an ID, otherwise the web request may end
+      // before the detached process has a chance to start.
+      $wait_until = $this->time->getCurrentTime() + 5;
+      do {
+        sleep(1);
+        $pid = $process->getPid();
+        if ($pid) {
+          break;
+        }
+      } while ($wait_until > $this->time->getCurrentTime());
+    }
+    catch (\Throwable $throwable) {
+      // @todo Just call Error::logException() in https://drupal.org/i/3377458.
+      if (method_exists(Error::class, 'logException')) {
+        Error::logException($this->logger, $throwable, 'Unable to start background update.');
       }
       else {
-        $mail_params['urgent'] = FALSE;
-        $key = 'cron_failed';
+        watchdog_exception('automatic_updates', $throwable, 'Unable to start background update.');
       }
-
-      foreach ($this->statusCheckMailer->getRecipients() as $email => $langcode) {
-        $this->mailManager->mail('automatic_updates', $key, $email, $langcode, $mail_params);
-      }
-      $this->logger->error($e->getMessage());
-
-      // If an error occurred during the pre-create event, the stage will be
-      // marked as available and we shouldn't try to destroy it, since the stage
-      // must be claimed in order to be destroyed.
-      if (!$this->isAvailable()) {
-        $this->destroy();
-      }
-      return $update_started;
     }
-    $this->triggerPostApply($stage_id, $installed_version, $target_version);
-    return TRUE;
-  }
 
-  /**
-   * Triggers the post-apply tasks.
-   *
-   * @param string $stage_id
-   *   The ID of the current stage.
-   * @param string $start_version
-   *   The version of Drupal core that started the update.
-   * @param string $target_version
-   *   The version of Drupal core to which we are updating.
-   */
-  protected function triggerPostApply(string $stage_id, string $start_version, string $target_version): void {
-    // Perform a subrequest to run ::postApply(), which needs to be done in a
-    // separate request.
-    // @see parent::apply()
-    $url = Url::fromRoute('automatic_updates.cron.post_apply', [
-      'stage_id' => $stage_id,
-      'installed_version' => $start_version,
-      'target_version' => $target_version,
-      'key' => $this->state->get('system.cron_key'),
-    ]);
-    $url = $url->setAbsolute()->toString();
-
-    // If we're using a single-threaded web server (e.g., the built-in PHP web
-    // server used in build tests), allow the post-apply request to be sent to
-    // an alternate port.
-    $port = $this->configFactory->get('automatic_updates.settings')
-      ->get('cron_port');
-    if ($port) {
-      $url = (string) (new GuzzleUri($url))->withPort($port);
+    if ($process->isTerminated()) {
+      if ($process->getExitCode() !== 0) {
+        $this->logger->error('Background update failed: %message', [
+          '%message' => $process->getErrorOutput(),
+        ]);
+      }
     }
-
-    // Use the bare cURL API to make the request, so that we're not relying on
-    // any third-party classes or other code which may have changed during the
-    // update.
-    $curl = curl_init($url);
-    curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
-    $response = curl_exec($curl);
-    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
-    if ($status !== 200) {
-      $this->logger->error('Post-apply tasks failed with output: %status %response', [
-        '%status' => $status,
-        '%response' => $response,
-      ]);
+    elseif (empty($pid)) {
+      $this->logger->error('Background update failed because the process did not start within 5 seconds.');
     }
-    curl_close($curl);
   }
 
   /**
-   * Runs post-apply tasks.
-   *
-   * @param string $stage_id
-   *   The stage ID.
-   * @param string $installed_version
-   *   The version of Drupal core that started the update.
-   * @param string $target_version
-   *   The version of Drupal core to which we updated.
+   * Indicates if we are currently running at the command line.
    *
-   * @return \Symfony\Component\HttpFoundation\Response
-   *   An empty 200 response if the post-apply tasks succeeded.
+   * @return bool
+   *   TRUE if we are running at the command line, otherwise FALSE.
    */
-  public function handlePostApply(string $stage_id, string $installed_version, string $target_version): Response {
-    $owner = $this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY)
-      ->getOwnerId();
-    // Reload the tempstore with the correct owner ID so we can claim the stage.
-    $this->tempStore = $this->tempStoreFactory->get('package_manager_stage', $owner);
-
-    $this->claim($stage_id);
-
-    $this->logger->info(
-      'Drupal core has been updated from %previous_version to %target_version',
-      [
-        '%previous_version' => $installed_version,
-        '%target_version' => $target_version,
-      ]
-    );
-
-    // Send notifications about the successful update.
-    $mail_params = [
-      'previous_version' => $installed_version,
-      'updated_version' => $target_version,
-    ];
-    foreach ($this->statusCheckMailer->getRecipients() as $recipient => $langcode) {
-      $this->mailManager->mail('automatic_updates', 'cron_successful', $recipient, $langcode, $mail_params);
-    }
-
-    // Run post-apply tasks in their own try-catch block so that, if anything
-    // raises an exception, we'll log it and proceed to destroy the stage as
-    // soon as possible (which is also what we do in ::performUpdate()).
-    try {
-      $this->postApply();
-    }
-    catch (\Throwable $e) {
-      $this->logger->error($e->getMessage());
-    }
-
-    // If any pre-destroy event subscribers raise validation errors, ensure they
-    // are formatted and logged. But if any pre- or post-destroy event
-    // subscribers throw another exception, don't bother catching it, since it
-    // will be caught and handled by the main cron service.
-    try {
-      $this->destroy();
-    }
-    catch (StageEventException $e) {
-      $this->logger->error($e->getMessage());
-    }
-
-    return new Response();
+  final public static function isCommandLine(): bool {
+    return self::$serverApi === 'cli';
   }
 
   /**
-   * Checks access to the post-apply route.
-   *
-   * @param string $key
-   *   The cron key.
-   *
-   * @return \Drupal\Core\Access\AccessResult
-   *   The access result.
+   * {@inheritdoc}
    */
-  public function postApplyAccess(string $key): AccessResult {
-    // The normal _system_cron_access check always disallows access if
-    // maintenance mode is turned on, but we stay in maintenance mode until
-    // post-apply tasks are finished. Therefore, we only want to check that the
-    // cron key is valid, and allow access if it is, regardless of whether or
-    // not we're in maintenance mode.
-    if ($key === $this->state->get('system.cron_key')) {
-      return AccessResult::allowed()->setCacheMaxAge(0);
+  public function run() {
+    // Always run the cron service before we trigger the update terminal
+    // command.
+    $decorated_cron_succeeded = $this->inner->run();
+
+    $method = $this->configFactory->get('automatic_updates.settings')
+      ->get('unattended.method');
+    // If we are configured to run updates via the web, and we're actually being
+    // accessed via the web (i.e., anything that isn't the command line), go
+    // ahead and try to do the update.
+    if ($method === 'web' && !self::isCommandLine()) {
+      $this->runTerminalUpdateCommand();
     }
-    return AccessResult::forbidden()->setCacheMaxAge(0);
+    return $decorated_cron_succeeded;
   }
 
   /**
@@ -423,11 +169,11 @@ class CronUpdateStage extends UpdateStage implements CronInterface {
    *
    * @return string
    *   The cron update mode. Will be one of the following constants:
-   *   - \Drupal\automatic_updates\CronUpdateStage::DISABLED if updates during
+   *   - self::DISABLED if updates during
    *     cron are entirely disabled.
-   *   - \Drupal\automatic_updates\CronUpdateStage::SECURITY only security
+   *   - self::SECURITY only security
    *     updates can be done during cron.
-   *   - \Drupal\automatic_updates\CronUpdateStage::ALL if all updates are
+   *   - self::ALL if all updates are
    *     allowed during cron.
    */
   final public function getMode(): string {
@@ -436,20 +182,24 @@ class CronUpdateStage extends UpdateStage implements CronInterface {
   }
 
   /**
-   * {@inheritdoc}
+   * Gets the command path.
+   *
+   * @return string
+   *   The command path.
+   *
+   * @throws \Exception
+   *   Thrown if command path does not exist.
+   *
+   * @todo Remove in https://drupal.org/i/3360485.
    */
-  public function run() {
-    $method = $this->configFactory->get('automatic_updates.settings')
-      ->get('unattended.method');
-
-    // If we are configured to run updates via the web, and we're actually being
-    // accessed via the web (i.e., anything that isn't the command line), go
-    // ahead and try to do the update. In all other circumstances, just run the
-    // normal cron handler.
-    if ($method === 'web' && !self::isCommandLine() && $this->handleCron()) {
-      return TRUE;
-    }
-    return $this->inner->run();
+  public function getCommandPath(): string {
+    // For some reason 'vendor/bin/drush' does not exist in build tests but this
+    // method will be removed entirely before beta.
+    $command_path = $this->pathLocator->getVendorDirectory() . '/drush/drush/drush';
+    if (!is_executable($command_path)) {
+      throw new \Exception("The Automatic Updates terminal command is not available at $command_path.");
+    }
+    return $command_path;
   }
 
 }
diff --git a/src/DrushUpdateStage.php b/src/DrushUpdateStage.php
index abfb53773e3f46e9f3c5ad8a5c925c3499a41493..9704b127ccbf786165803db65c6b50308c8d5eca 100644
--- a/src/DrushUpdateStage.php
+++ b/src/DrushUpdateStage.php
@@ -4,17 +4,245 @@ declare(strict_types = 1);
 
 namespace Drupal\automatic_updates;
 
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Lock\LockBackendInterface;
+use Drupal\Core\Mail\MailManagerInterface;
+use Drupal\Core\TempStore\SharedTempStoreFactory;
+use Drupal\Core\Url;
+use Drupal\package_manager\ComposerInspector;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Exception\ApplyFailedException;
+use Drupal\package_manager\Exception\StageEventException;
+use Drupal\package_manager\Exception\StageFailureMarkerException;
+use Drupal\package_manager\FailureMarker;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\ProjectInfo;
+use Drupal\update\ProjectRelease;
 use Drush\Drush;
+use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
+use PhpTuf\ComposerStager\API\Core\CommitterInterface;
+use PhpTuf\ComposerStager\API\Core\StagerInterface;
+use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
 /**
  * An updater that runs via a Drush command.
+ *
+ * @todo Make this class a generic console stage in https://drupal.org/i/3360485
  */
-final class DrushUpdateStage extends CronUpdateStage {
+class DrushUpdateStage extends UpdateStage {
+
+  /**
+   * Constructs a DrushUpdateStage object.
+   *
+   * @param \Drupal\Core\Lock\LockBackendInterface $lock
+   *   The lock service.
+   * @param \Drupal\automatic_updates\CronUpdateStage $cronUpdateRunner
+   *   The cron update runner service.
+   * @param \Drupal\Core\Mail\MailManagerInterface $mailManager
+   *   The mail manager service.
+   * @param \Drupal\automatic_updates\StatusCheckMailer $statusCheckMailer
+   *   The status check mailer service.
+   * @param \Drupal\automatic_updates\ReleaseChooser $releaseChooser
+   *   The cron release chooser service.
+   * @param \Drupal\package_manager\ComposerInspector $composerInspector
+   *   The Composer inspector service.
+   * @param \Drupal\package_manager\PathLocator $pathLocator
+   *   The path locator service.
+   * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner
+   *   The beginner service.
+   * @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager
+   *   The stager service.
+   * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer
+   *   The committer service.
+   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
+   *   The file system service.
+   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
+   *   The event dispatcher service.
+   * @param \Drupal\Core\TempStore\SharedTempStoreFactory $tempStoreFactory
+   *   The shared tempstore factory.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory
+   *   The path factory service.
+   * @param \Drupal\package_manager\FailureMarker $failureMarker
+   *   The failure marker service.
+   */
+  public function __construct(
+    private readonly LockBackendInterface $lock,
+    private readonly CronUpdateStage $cronUpdateRunner,
+    private readonly MailManagerInterface $mailManager,
+    private readonly StatusCheckMailer $statusCheckMailer,
+    private readonly ReleaseChooser $releaseChooser,
+    ComposerInspector $composerInspector,
+    PathLocator $pathLocator,
+    BeginnerInterface $beginner,
+    StagerInterface $stager,
+    CommitterInterface $committer,
+    FileSystemInterface $fileSystem,
+    EventDispatcherInterface $eventDispatcher,
+    SharedTempStoreFactory $tempStoreFactory,
+    TimeInterface $time,
+    PathFactoryInterface $pathFactory,
+    FailureMarker $failureMarker,
+  ) {
+    parent::__construct($composerInspector, $pathLocator, $beginner, $stager, $committer, $fileSystem, $eventDispatcher, $tempStoreFactory, $time, $pathFactory, $failureMarker);
+  }
+
+  /**
+   * Returns the release of Drupal core to update to, if any.
+   *
+   * @return \Drupal\update\ProjectRelease|null
+   *   The release of Drupal core to which we will update, or NULL if there is
+   *   nothing to update to.
+   */
+  public function getTargetRelease(): ?ProjectRelease {
+    return $this->releaseChooser->getLatestInInstalledMinor($this);
+  }
 
   /**
    * {@inheritdoc}
    */
-  protected function triggerPostApply(string $stage_id, string $start_version, string $target_version): void {
+  final public function begin(array $project_versions, ?int $timeout = 300): never {
+    // Unattended updates should never be started using this method. They should
+    // only be done by ::performUpdate(), which has a strong opinion about which
+    // release to update to and will call ::setProcessStatus(). Throwing an
+    // exception here is just to enforce this boundary. To update to a specific
+    // version of core, use \Drupal\automatic_updates\UpdateStage::begin()
+    // (which is called in::performUpdate() to start the update to the target
+    // version of core chosen by ::getTargetRelease()).
+    throw new \BadMethodCallException(__METHOD__ . '() cannot be called directly.');
+  }
+
+  /**
+   * Performs the update.
+   *
+   * @param bool $is_from_web
+   *   Whether the current process was started from a web request. This is not
+   *   used by this method specifically, but it's passed through to post-apply
+   *   to ensure that status checks are only run as the web server user if the
+   *   update is being trigger via the web.
+   *
+   * @return bool
+   *   Returns TRUE if any update was attempted, otherwise FALSE.
+   */
+  public function performUpdate(bool $is_from_web = FALSE): bool {
+    if ($this->cronUpdateRunner->getMode() === CronUpdateStage::DISABLED) {
+      return FALSE;
+    }
+
+    $next_release = $this->getTargetRelease();
+    if (!$next_release) {
+      return FALSE;
+    }
+    $target_version = $next_release->getVersion();
+    $project_info = new ProjectInfo('drupal');
+    $update_started = FALSE;
+
+    if (!$this->isAvailable()) {
+      if ($project_info->isInstalledVersionSafe() && !$this->isApplying()) {
+        $this->logger->notice('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.');
+        return $update_started;
+      }
+      if (!$project_info->isInstalledVersionSafe() && $this->isApplying()) {
+        $this->logger->notice(
+          'Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href="%url">update form</a>.',
+          ['%url' => Url::fromRoute('update.report_update')->setAbsolute()->toString()],
+        );
+        return $update_started;
+      }
+    }
+
+    // Delete the existing staging area if not available and the site is
+    // currently on an insecure version.
+    if (!$project_info->isInstalledVersionSafe() && !$this->isAvailable() && !$this->isApplying()) {
+      $destroy_message = $this->t('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.');
+      $this->destroy(TRUE, $destroy_message);
+      $this->logger->notice($destroy_message->getUntranslatedString());
+    }
+
+    $installed_version = $project_info->getInstalledVersion();
+    if (empty($installed_version)) {
+      $this->logger->error('Unable to determine the current version of Drupal core.');
+      return $update_started;
+    }
+    if (!$this->lock->acquire('cron', 600)) {
+      $this->logger->error('Unable to start Drupal core update because cron is running.');
+      return $update_started;
+    }
+
+    // Do the bulk of the update in its own try-catch structure, so that we can
+    // handle any exceptions or validation errors consistently, and destroy the
+    // stage regardless of whether the update succeeds.
+    try {
+      $update_started = TRUE;
+      // @see ::begin()
+      $stage_id = parent::begin(['drupal' => $target_version], 300);
+      $this->stage();
+      $this->apply();
+    }
+    catch (\Throwable $e) {
+      $this->lock->release('cron');
+      if ($e instanceof StageEventException && $e->event instanceof PreCreateEvent) {
+        // If the error happened during PreCreateEvent then the update did not
+        // really start.
+        $update_started = FALSE;
+      }
+      // Send notifications about the failed update.
+      $mail_params = [
+        'previous_version' => $installed_version,
+        'target_version' => $target_version,
+        'error_message' => $e->getMessage(),
+      ];
+      // Omit the backtrace in e-mails. That will be visible on the site, and is
+      // also stored in the failure marker.
+      if ($e instanceof StageFailureMarkerException || $e instanceof ApplyFailedException) {
+        $mail_params['error_message'] = $this->failureMarker->getMessage(FALSE);
+      }
+      if ($e instanceof ApplyFailedException) {
+        $mail_params['urgent'] = TRUE;
+        $key = 'cron_failed_apply';
+      }
+      elseif (!$project_info->isInstalledVersionSafe()) {
+        $mail_params['urgent'] = TRUE;
+        $key = 'cron_failed_insecure';
+      }
+      else {
+        $mail_params['urgent'] = FALSE;
+        $key = 'cron_failed';
+      }
+
+      foreach ($this->statusCheckMailer->getRecipients() as $email => $langcode) {
+        $this->mailManager->mail('automatic_updates', $key, $email, $langcode, $mail_params);
+      }
+      $this->logger->error($e->getMessage());
+
+      // If an error occurred during the pre-create event, the stage will be
+      // marked as available and we shouldn't try to destroy it, since the stage
+      // must be claimed in order to be destroyed.
+      if (!$this->isAvailable()) {
+        $this->destroy();
+      }
+      return $update_started;
+    }
+    $this->triggerPostApply($stage_id, $installed_version, $target_version, $is_from_web);
+    return TRUE;
+  }
+
+  /**
+   * Runs the post apply command.
+   *
+   * @param string $stage_id
+   *   The ID of the current stage.
+   * @param string $start_version
+   *   The version of Drupal core that started the update.
+   * @param string $target_version
+   *   The version of Drupal core to which we are updating.
+   * @param bool $is_from_web
+   *   Whether or not the update command was run from the web.
+   */
+  protected function triggerPostApply(string $stage_id, string $start_version, string $target_version, bool $is_from_web): void {
     $alias = Drush::aliasManager()->getSelf();
 
     $output = Drush::processManager()
@@ -23,6 +251,7 @@ final class DrushUpdateStage extends CronUpdateStage {
         'stage-id' => $stage_id,
         'from-version' => $start_version,
         'to-version' => $target_version,
+        'is-from-web' => $is_from_web,
       ])
       ->mustRun()
       ->getOutput();
@@ -31,11 +260,61 @@ final class DrushUpdateStage extends CronUpdateStage {
   }
 
   /**
-   * {@inheritdoc}
+   * Runs post-apply tasks.
+   *
+   * @param string $stage_id
+   *   The stage ID.
+   * @param string $installed_version
+   *   The version of Drupal core that started the update.
+   * @param string $target_version
+   *   The version of Drupal core to which we updated.
    */
-  public function performUpdate(string $target_version, ?int $timeout): bool {
-    // Overridden to expose this method to calling code.
-    return parent::performUpdate($target_version, $timeout);
+  public function handlePostApply(string $stage_id, string $installed_version, string $target_version): void {
+    $owner = $this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY)
+      ->getOwnerId();
+    // Reload the tempstore with the correct owner ID so we can claim the stage.
+    $this->tempStore = $this->tempStoreFactory->get('package_manager_stage', $owner);
+
+    $this->claim($stage_id);
+
+    $this->logger->info(
+      'Drupal core has been updated from %previous_version to %target_version',
+      [
+        '%previous_version' => $installed_version,
+        '%target_version' => $target_version,
+      ]
+    );
+
+    // Send notifications about the successful update.
+    $mail_params = [
+      'previous_version' => $installed_version,
+      'updated_version' => $target_version,
+    ];
+    foreach ($this->statusCheckMailer->getRecipients() as $recipient => $langcode) {
+      $this->mailManager->mail('automatic_updates', 'cron_successful', $recipient, $langcode, $mail_params);
+    }
+
+    // Run post-apply tasks in their own try-catch block so that, if anything
+    // raises an exception, we'll log it and proceed to destroy the stage as
+    // soon as possible (which is also what we do in ::performUpdate()).
+    try {
+      $this->postApply();
+    }
+    catch (\Throwable $e) {
+      $this->logger->error($e->getMessage());
+    }
+    $this->lock->release('cron');
+
+    // If any pre-destroy event subscribers raise validation errors, ensure they
+    // are formatted and logged. But if any pre- or post-destroy event
+    // subscribers throw another exception, don't bother catching it, since it
+    // will be caught and handled by the main cron service.
+    try {
+      $this->destroy();
+    }
+    catch (StageEventException $e) {
+      $this->logger->error($e->getMessage());
+    }
   }
 
 }
diff --git a/src/MaintenanceModeAwareCommitter.php b/src/MaintenanceModeAwareCommitter.php
index ae0c9c50817a19db0435e4a236b25660df08f292..1599bc30491f28fc173b47b6ce634e375e73d968 100644
--- a/src/MaintenanceModeAwareCommitter.php
+++ b/src/MaintenanceModeAwareCommitter.php
@@ -61,7 +61,7 @@ final class MaintenanceModeAwareCommitter implements CommitterInterface, EventSu
    *   The event being handled.
    */
   public function restore(PostApplyEvent $event): void {
-    if ($event->stage instanceof CronUpdateStage) {
+    if ($event->stage instanceof DrushUpdateStage) {
       $this->doRestore();
     }
   }
diff --git a/src/Validation/StatusChecker.php b/src/Validation/StatusChecker.php
index 99116b89fecf9c3a7e27fa238acf3ca74e4cb00b..a8e3f06409e2e3a934a001f22adbcccf24d0a567 100644
--- a/src/Validation/StatusChecker.php
+++ b/src/Validation/StatusChecker.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\automatic_updates\Validation;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\automatic_updates\StatusCheckMailer;
 use Drupal\Core\Config\ConfigCrudEvent;
 use Drupal\Core\Config\ConfigEvents;
@@ -42,6 +43,8 @@ final class StatusChecker implements EventSubscriberInterface {
    *   The event dispatcher service.
    * @param \Drupal\automatic_updates\UpdateStage $updateStage
    *   The update stage service.
+   * @param \Drupal\automatic_updates\DrushUpdateStage $consoleUpdateStage
+   *   The console update stage service.
    * @param \Drupal\automatic_updates\CronUpdateStage $cronUpdateStage
    *   The cron update stage service.
    * @param int $resultsTimeToLive
@@ -52,6 +55,7 @@ final class StatusChecker implements EventSubscriberInterface {
     private readonly TimeInterface $time,
     private readonly EventDispatcherInterface $eventDispatcher,
     private readonly UpdateStage $updateStage,
+    private readonly DrushUpdateStage $consoleUpdateStage,
     private readonly CronUpdateStage $cronUpdateStage,
     private readonly int $resultsTimeToLive,
   ) {
@@ -64,14 +68,14 @@ final class StatusChecker implements EventSubscriberInterface {
    * @return $this
    */
   public function run(): self {
-    // If updates will run during cron, use the cron update stage service
+    // If updates will run during cron, use the console update stage service
     // provided by this module. This will allow validators to run specific
     // validation for conditions that only affect cron updates.
     if ($this->cronUpdateStage->getMode() === CronUpdateStage::DISABLED) {
       $stage = $this->updateStage;
     }
     else {
-      $stage = $this->cronUpdateStage;
+      $stage = $this->consoleUpdateStage;
     }
     $results = $this->runStatusCheck($stage, $this->eventDispatcher);
 
diff --git a/src/Validator/AutomatedCronDisabledValidator.php b/src/Validator/AutomatedCronDisabledValidator.php
deleted file mode 100644
index a98541d9d579aeda74e4b7452ef30d3d44f6aa1c..0000000000000000000000000000000000000000
--- a/src/Validator/AutomatedCronDisabledValidator.php
+++ /dev/null
@@ -1,89 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\automatic_updates\Validator;
-
-use Drupal\automatic_updates\CronUpdateStage;
-use Drupal\Core\Extension\ModuleHandlerInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\package_manager\Event\StatusCheckEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpKernel\KernelEvents;
-
-/**
- * Ensures that updates cannot be triggered by Automated Cron.
- *
- * @internal
- *   This is an internal part of Automatic Updates and may be changed or removed
- *   at any time without warning. External code should not interact with this
- *   class.
- */
-final class AutomatedCronDisabledValidator implements EventSubscriberInterface {
-
-  use StringTranslationTrait;
-
-  /**
-   * Flags whether the KernelEvents::TERMINATE event has been dispatched.
-   *
-   * @var bool
-   */
-  private bool $terminateCalled = FALSE;
-
-  /**
-   * AutomatedCronDisabledValidator constructor.
-   *
-   * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
-   *   The module handler.
-   */
-  public function __construct(
-    private readonly ModuleHandlerInterface $moduleHandler,
-  ) {}
-
-  /**
-   * Checks that Automated Cron is not going to trigger unattended updates.
-   *
-   * @param \Drupal\package_manager\Event\StatusCheckEvent $event
-   *   The event being handled.
-   */
-  public function validateStatusCheck(StatusCheckEvent $event): void {
-    if ($event->stage instanceof CronUpdateStage && $this->moduleHandler->moduleExists('automated_cron')) {
-      $event->addWarning([
-        $this->t('This site has the Automated Cron module installed. To use unattended automatic updates, configure cron manually on your hosting environment. The Automatic Updates module will not do anything if it is triggered by Automated Cron. See the <a href=":url">Automated Cron documentation</a> for information.', [
-          ':url' => 'https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview#s-more-reliable-enable-cron-using-external-trigger',
-        ]),
-      ]);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents(): array {
-    return [
-      StatusCheckEvent::class => 'validateStatusCheck',
-      // Ensure this runs before
-      // \Drupal\automated_cron\EventSubscriber\AutomatedCron::onTerminate().
-      KernelEvents::TERMINATE => ['setTerminateCalled', PHP_INT_MAX],
-    ];
-  }
-
-  /**
-   * Sets a flag is when the kernel terminates.
-   */
-  public function setTerminateCalled(): void {
-    $this->terminateCalled = TRUE;
-  }
-
-  /**
-   * Determines whether the kernel has terminated.
-   *
-   * @return bool
-   *   TRUE if the kernel has terminated (i.e., KernelEvents::TERMINATE has been
-   *   handled), otherwise FALSE.
-   */
-  public function hasTerminateBeenCalled(): bool {
-    return $this->terminateCalled;
-  }
-
-}
diff --git a/src/Validator/CronFrequencyValidator.php b/src/Validator/CronFrequencyValidator.php
index 2a5aa09c452e8cc35a344c6ffb4ee738a26092fe..144e3a911abb71f5c6e940512dea4f6006188b62 100644
--- a/src/Validator/CronFrequencyValidator.php
+++ b/src/Validator/CronFrequencyValidator.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\automatic_updates\Validator;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Lock\LockBackendInterface;
@@ -55,6 +56,8 @@ final class CronFrequencyValidator implements EventSubscriberInterface {
   /**
    * CronFrequencyValidator constructor.
    *
+   * @param \Drupal\automatic_updates\CronUpdateStage $cronUpdateRunner
+   *   The cron update runner service.
    * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
    *   The config factory service.
    * @param \Drupal\Core\State\StateInterface $state
@@ -65,6 +68,7 @@ final class CronFrequencyValidator implements EventSubscriberInterface {
    *   The lock service.
    */
   public function __construct(
+    private readonly CronUpdateStage $cronUpdateRunner,
     private readonly ConfigFactoryInterface $configFactory,
     private readonly StateInterface $state,
     private readonly TimeInterface $time,
@@ -79,14 +83,14 @@ final class CronFrequencyValidator implements EventSubscriberInterface {
    */
   public function validateLastCronRun(StatusCheckEvent $event): void {
     // We only want to do this check if the stage belongs to Automatic Updates.
-    if (!$event->stage instanceof CronUpdateStage) {
+    if (!$event->stage instanceof DrushUpdateStage) {
       return;
     }
     // If automatic updates are disabled during cron or updates will be run via
     // the console command, there's nothing we need to validate.
     $method = $this->configFactory->get('automatic_updates.settings')
       ->get('unattended.method');
-    if ($event->stage->getMode() === CronUpdateStage::DISABLED || $method !== 'web') {
+    if ($this->cronUpdateRunner->getMode() === CronUpdateStage::DISABLED || $method !== 'web') {
       return;
     }
     // If cron is running right now, cron is clearly being run recently enough!
diff --git a/src/Validator/CronServerValidator.php b/src/Validator/CronServerValidator.php
deleted file mode 100644
index 71aa1ed7438b8b7fd07e7682b6efa8c2200723ab..0000000000000000000000000000000000000000
--- a/src/Validator/CronServerValidator.php
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\automatic_updates\Validator;
-
-use Drupal\automatic_updates\CronUpdateStage;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Extension\ModuleHandlerInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\Url;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\PreOperationStageEvent;
-use Drupal\package_manager\Event\StatusCheckEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\RequestStack;
-
-/**
- * Validates that the current server configuration can run cron updates.
- *
- * @internal
- *   This is an internal part of Automatic Updates and may be changed or removed
- *   at any time without warning. External code should not interact with this
- *   class.
- */
-final class CronServerValidator implements EventSubscriberInterface {
-
-  use StringTranslationTrait;
-
-  /**
-   * The current request.
-   *
-   * @var \Symfony\Component\HttpFoundation\Request
-   */
-  private readonly Request $request;
-
-  /**
-   * The type of interface between the web server and the PHP runtime.
-   *
-   * @var string
-   *
-   * @see php_sapi_name()
-   * @see https://www.php.net/manual/en/reserved.constants.php
-   */
-  private static $serverApi = PHP_SAPI;
-
-  /**
-   * Constructs a CronServerValidator object.
-   *
-   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
-   *   The request stack service.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
-   *   The config factory service.
-   * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
-   *   The module handler service.
-   */
-  public function __construct(
-    RequestStack $request_stack,
-    private readonly ConfigFactoryInterface $configFactory,
-    private readonly ModuleHandlerInterface $moduleHandler,
-  ) {
-    $this->request = $request_stack->getCurrentRequest();
-  }
-
-  /**
-   * Checks that the server is configured correctly to run cron updates.
-   *
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event object.
-   */
-  public function checkServer(PreOperationStageEvent $event): void {
-    if (!$event->stage instanceof CronUpdateStage) {
-      return;
-    }
-
-    $current_port = (int) $this->request->getPort();
-
-    $alternate_port = $this->configFactory->get('automatic_updates.settings')
-      ->get('cron_port');
-    // If no alternate port is configured, it's the same as the current port.
-    $alternate_port = intval($alternate_port) ?: $current_port;
-
-    if (static::$serverApi === 'cli-server' && $current_port === $alternate_port) {
-      $message = $this->t('Your site appears to be running on the built-in PHP web server on port @port. Drupal cannot be automatically updated with this configuration unless the site can also be reached on an alternate port.', [
-        '@port' => $current_port,
-      ]);
-      if ($this->moduleHandler->moduleExists('help')) {
-        $url = Url::fromRoute('help.page')
-          ->setRouteParameter('name', 'automatic_updates')
-          ->setOption('fragment', 'cron-alternate-port')
-          ->toString();
-
-        $message = $this->t('@message See <a href=":url">the Automatic Updates help page</a> for more information on how to resolve this.', [
-          '@message' => $message,
-          ':url' => $url,
-        ]);
-      }
-
-      $event->addError([$message]);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents(): array {
-    return [
-      PreCreateEvent::class => 'checkServer',
-      PreApplyEvent::class => 'checkServer',
-      StatusCheckEvent::class => 'checkServer',
-    ];
-  }
-
-}
diff --git a/src/Validator/PhpExtensionsValidator.php b/src/Validator/PhpExtensionsValidator.php
index 958bb8536b68ddab578c6f11757caeeb7860a8ed..9885146661255355582f3e5d5df8fd84c86a4945 100644
--- a/src/Validator/PhpExtensionsValidator.php
+++ b/src/Validator/PhpExtensionsValidator.php
@@ -4,10 +4,10 @@ declare(strict_types = 1);
 
 namespace Drupal\automatic_updates\Validator;
 
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\StatusCheckEvent;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Drupal\automatic_updates\CronUpdateStage;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\package_manager\Validator\PhpExtensionsValidator as PackageManagerPhpExtensionsValidator;
@@ -26,7 +26,7 @@ final class PhpExtensionsValidator extends PackageManagerPhpExtensionsValidator
    * {@inheritdoc}
    */
   public function validateXdebug(PreOperationStageEvent $event): void {
-    if ($this->isExtensionLoaded('xdebug') && $event->stage instanceof CronUpdateStage) {
+    if ($this->isExtensionLoaded('xdebug') && $event->stage instanceof DrushUpdateStage) {
       $event->addError([$this->t("Unattended updates are not allowed while Xdebug is enabled. You cannot receive updates, including security updates, until it is disabled.")]);
     }
     elseif ($event instanceof StatusCheckEvent) {
diff --git a/src/Validator/StagedDatabaseUpdateValidator.php b/src/Validator/StagedDatabaseUpdateValidator.php
index e04da6c289907277f7cc42b911be7f2064df30f6..e2998ddb5d191bcf25ec1dae3e97f8ffaea1cc9d 100644
--- a/src/Validator/StagedDatabaseUpdateValidator.php
+++ b/src/Validator/StagedDatabaseUpdateValidator.php
@@ -4,7 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\automatic_updates\Validator;
 
-use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Validator\StagedDBUpdateValidator;
@@ -39,7 +39,7 @@ final class StagedDatabaseUpdateValidator implements EventSubscriberInterface {
    */
   public function checkUpdateHooks(PreApplyEvent $event): void {
     $stage = $event->stage;
-    if (!$stage instanceof CronUpdateStage) {
+    if (!$stage instanceof DrushUpdateStage) {
       return;
     }
 
diff --git a/src/Validator/VersionPolicyValidator.php b/src/Validator/VersionPolicyValidator.php
index 32866729c0cbfabe84f5bd5f8c5f611554ff7007..9b671b4770bf84e9ae88f250677b74c7ac92c398 100644
--- a/src/Validator/VersionPolicyValidator.php
+++ b/src/Validator/VersionPolicyValidator.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\automatic_updates\Validator;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\package_manager\ComposerInspector;
 use Drupal\package_manager\Event\StatusCheckEvent;
@@ -41,6 +42,8 @@ final class VersionPolicyValidator implements EventSubscriberInterface {
   /**
    * Constructs a VersionPolicyValidator object.
    *
+   * @param \Drupal\automatic_updates\CronUpdateStage $cronUpdateRunner
+   *   The cron update runner service.
    * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $classResolver
    *   The class resolver service.
    * @param \Drupal\package_manager\PathLocator $pathLocator
@@ -49,6 +52,7 @@ final class VersionPolicyValidator implements EventSubscriberInterface {
    *   The Composer inspector service.
    */
   public function __construct(
+    private readonly CronUpdateStage $cronUpdateRunner,
     private readonly ClassResolverInterface $classResolver,
     private readonly PathLocator $pathLocator,
     private readonly ComposerInspector $composerInspector,
@@ -85,8 +89,8 @@ final class VersionPolicyValidator implements EventSubscriberInterface {
     }
 
     // If this is a cron update, we may need to do additional checks.
-    if ($stage instanceof CronUpdateStage) {
-      $mode = $stage->getMode();
+    if ($stage instanceof DrushUpdateStage) {
+      $mode = $this->cronUpdateRunner->getMode();
 
       if ($mode !== CronUpdateStage::DISABLED) {
         // If cron updates are enabled, the installed version must be stable;
@@ -235,7 +239,7 @@ final class VersionPolicyValidator implements EventSubscriberInterface {
       }
     }
     elseif ($event instanceof StatusCheckEvent) {
-      if ($stage instanceof CronUpdateStage) {
+      if ($stage instanceof DrushUpdateStage) {
         $target_release = $stage->getTargetRelease();
         if ($target_release) {
           return $target_release->getVersion();
@@ -264,7 +268,7 @@ final class VersionPolicyValidator implements EventSubscriberInterface {
     $project_info = new ProjectInfo('drupal');
     $available_releases = $project_info->getInstallableReleases() ?? [];
 
-    if ($stage instanceof CronUpdateStage) {
+    if ($stage instanceof DrushUpdateStage) {
       $available_releases = array_reverse($available_releases);
     }
     return $available_releases;
diff --git a/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml b/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml
index 821c91b431953ba82f05765d2f9891034d6a5a51..b7cd7895f2e8dd75ef4c647a97f42ae0f7481975 100644
--- a/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml
+++ b/tests/modules/automatic_updates_test_api/automatic_updates_test_api.routing.yml
@@ -12,3 +12,12 @@ automatic_updates_test_api.finish:
     _controller: 'Drupal\automatic_updates_test_api\ApiController::finish'
   requirements:
     _access: 'TRUE'
+automatic_updates_test_api.reset_cron:
+  path: '/automatic-updates-test-api/reset-cron'
+  defaults:
+    _controller: 'Drupal\automatic_updates_test_api\ApiController::resetCron'
+  requirements:
+    _access: 'TRUE'
+  options:
+    _maintenance_access: TRUE
+    no_cache: TRUE
diff --git a/tests/modules/automatic_updates_test_api/src/ApiController.php b/tests/modules/automatic_updates_test_api/src/ApiController.php
index 5790f3adebd0e684eda7e07e6520f65ba0da222d..8d99628226df3d951d3ef2a900233a82aaddc47d 100644
--- a/tests/modules/automatic_updates_test_api/src/ApiController.php
+++ b/tests/modules/automatic_updates_test_api/src/ApiController.php
@@ -7,6 +7,7 @@ namespace Drupal\automatic_updates_test_api;
 use Drupal\package_manager_test_api\ApiController as PackageManagerApiController;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
 
 class ApiController extends PackageManagerApiController {
 
@@ -35,4 +36,15 @@ class ApiController extends PackageManagerApiController {
     return $id;
   }
 
+  /**
+   * Deletes last cron run time, so Automated Cron will run during this request.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   The response.
+   */
+  public function resetCron(): Response {
+    \Drupal::state()->delete('system.cron_last');
+    return new Response('cron reset');
+  }
+
 }
diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php
index 9f0fcef9770d7a0535d067610da36561716920f4..c008c2fb4264df261d9c0ba6cc47c5db385da288 100644
--- a/tests/src/Build/CoreUpdateTest.php
+++ b/tests/src/Build/CoreUpdateTest.php
@@ -6,7 +6,6 @@ namespace Drupal\Tests\automatic_updates\Build;
 
 use Behat\Mink\Element\DocumentElement;
 use Drupal\automatic_updates\DrushUpdateStage;
-use Drupal\automatic_updates\CronUpdateStage;
 use Drupal\automatic_updates\UpdateStage;
 use Drupal\package_manager\Event\PostApplyEvent;
 use Drupal\package_manager\Event\PostCreateEvent;
@@ -66,7 +65,7 @@ class CoreUpdateTest extends UpdateTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function createTestProject(string $template): void {
+  protected function createTestProject(string $template, bool $require_drush = FALSE): void {
     parent::createTestProject($template);
 
     // Prepare an "upstream" version of core, 9.8.1, to which we will update.
@@ -89,6 +88,13 @@ class CoreUpdateTest extends UpdateTestBase {
 
     // Ensure that Drupal has write-protected the site directory.
     $this->assertDirectoryIsNotWritable($this->getWebRoot() . '/sites/default');
+
+    // @todo Remove along with $require_drush parameter in
+    //   https://drupal.org/i/3360485.
+    if ($require_drush) {
+      $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drush/drush', 'project');
+      $this->assertStringNotContainsString('Symlinking', $output);
+    }
   }
 
   /**
@@ -130,7 +136,7 @@ class CoreUpdateTest extends UpdateTestBase {
         PreDestroyEvent::class,
         PostDestroyEvent::class,
       ],
-      'Error response: ' . $file_contents
+      message: 'Error response: ' . $file_contents
     );
     // Even though the response is what we expect, assert the status code as
     // well, to be extra-certain that there was no kind of server-side error.
@@ -151,6 +157,21 @@ class CoreUpdateTest extends UpdateTestBase {
     ]);
   }
 
+  /**
+   * Tests updating during cron using the Automated Cron module.
+   */
+  public function testAutomatedCron(): void {
+    $this->createTestProject('RecommendedProject', TRUE);
+    $this->installModules(['automated_cron']);
+
+    // Reset the record of the last cron run, so that Automated Cron will be
+    // triggered at the end of this request.
+    $this->visit('/automatic-updates-test-api/reset-cron');
+    $this->getMink()->assertSession()->pageTextContains('cron reset');
+    $this->assertExpectedStageEventsFired(DrushUpdateStage::class, wait: 360);
+    $this->assertCronUpdateSuccessful();
+  }
+
   /**
    * Tests an end-to-end core update via the UI.
    */
@@ -190,35 +211,22 @@ class CoreUpdateTest extends UpdateTestBase {
    * @dataProvider providerTemplate
    */
   public function testCron(string $template): void {
-    $this->createTestProject($template);
+    $this->createTestProject($template, TRUE);
 
     $this->visit('/admin/reports/status');
-    $mink = $this->getMink();
-    $page = $mink->getSession()->getPage();
-    $assert_session = $mink->assertSession();
-    $page->clickLink('Run cron');
-    $cron_run_status_code = $mink->getSession()->getStatusCode();
-    $this->assertExpectedStageEventsFired(CronUpdateStage::class);
-    $this->assertSame(200, $cron_run_status_code);
+    $session = $this->getMink()->getSession();
 
-    $mink = $this->getMink();
-    $page = $mink->getSession()->getPage();
-    $assert_session = $mink->assertSession();
-    $this->visit('/admin/reports/dblog');
-    // Ensure that the update was logged.
-    $page->selectFieldOption('Severity', 'Info');
-    $page->pressButton('Filter');
-    // There should be a log entry about the successful update.
-    $log_entry = $assert_session->elementExists('named', ['link', 'Drupal core has been updated from 9.8.0 to 9.8.1']);
-    $this->assertStringContainsString('/admin/reports/dblog/event/', $log_entry->getAttribute('href'));
-    $this->assertUpdateSuccessful('9.8.1');
+    $session->getPage()->clickLink('Run cron');
+    $this->assertSame(200, $session->getStatusCode());
+    $this->assertExpectedStageEventsFired(DrushUpdateStage::class, wait: 360);
+    $this->assertCronUpdateSuccessful();
   }
 
   /**
    * Tests stage is destroyed if not available and site is on insecure version.
    */
   public function testStageDestroyedIfNotAvailable(): void {
-    $this->createTestProject('RecommendedProject');
+    $this->createTestProject('RecommendedProject', TRUE);
     $mink = $this->getMink();
     $session = $mink->getSession();
     $page = $session->getPage();
@@ -227,7 +235,22 @@ class CoreUpdateTest extends UpdateTestBase {
     $this->visit('/admin/reports/status');
     $assert_session->pageTextContains('Your site is ready for automatic updates.');
     $page->clickLink('Run cron');
-    $this->assertUpdateSuccessful('9.8.1');
+    // The stage will first destroy the stage made above before going through
+    // stage lifecycle events for the cron update.
+    $expected_events = [
+      PreDestroyEvent::class,
+      PostDestroyEvent::class,
+      PreCreateEvent::class,
+      PostCreateEvent::class,
+      PreRequireEvent::class,
+      PostRequireEvent::class,
+      PreApplyEvent::class,
+      PostApplyEvent::class,
+      PreDestroyEvent::class,
+      PostDestroyEvent::class,
+    ];
+    $this->assertExpectedStageEventsFired(DrushUpdateStage::class, $expected_events, 360);
+    $this->assertCronUpdateSuccessful();
   }
 
   /**
@@ -366,7 +389,9 @@ class CoreUpdateTest extends UpdateTestBase {
     $this->assertSame($expected_version, $info['devRequires']['drupal/core-dev']);
     // The update form should not have any available updates.
     $this->visit('/admin/modules/update');
-    $this->getMink()->assertSession()->pageTextContains('No update available');
+    $assert_session = $this->getMink()->assertSession();
+    $assert_session->pageTextContains('No update available');
+    $assert_session->pageTextNotContains('Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.');
 
     // The status page should report that we're running the expected version and
     // the README and default site configuration files should contain the
@@ -408,16 +433,30 @@ class CoreUpdateTest extends UpdateTestBase {
     $assert_session->pageTextContains('Ready to update');
   }
 
+  /**
+   * Assert a cron update ran successfully.
+   */
+  private function assertCronUpdateSuccessful(): void {
+    $mink = $this->getMink();
+    $page = $mink->getSession()->getPage();
+    $this->visit('/admin/reports/dblog');
+
+    // Ensure that the update occurred.
+    $page->selectFieldOption('Severity', 'Info');
+    $page->pressButton('Filter');
+    // There should be a log entry about the successful update.
+    $mink->assertSession()
+      ->elementAttributeContains('named', ['link', 'Drupal core has been updated from 9.8.0 to 9.8.1'], 'href', '/admin/reports/dblog/event/');
+    $this->assertUpdateSuccessful('9.8.1');
+  }
+
   // BEGIN: DELETE FROM CORE MERGE REQUEST
 
   /**
    * Tests updating via Drush.
    */
   public function testDrushUpdate(): void {
-    $this->createTestProject('RecommendedProject');
-
-    $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drush/drush', 'project');
-    $this->assertStringNotContainsString('Symlinking', $output);
+    $this->createTestProject('RecommendedProject', TRUE);
 
     $dir = $this->getWorkspaceDirectory() . '/project';
     $command = [
diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php
index 7a960713f9abcfaa24f3b5dd484cb931b57ad095..a1f6b7befe609291efb784ba094da551962b3c54 100644
--- a/tests/src/Build/UpdateTestBase.php
+++ b/tests/src/Build/UpdateTestBase.php
@@ -30,11 +30,12 @@ END;
       'automatic_updates_test_api',
     ]);
 
-    // Uninstall Automated Cron, which is not supported by Automatic Updates.
+    // Uninstall Automated Cron because this will run cron updates on most
+    // requests, making it difficult to test other forms of updating.
     // Also uninstall Big Pipe, since it may cause page elements to be rendered
     // in the background and replaced with JavaScript, which isn't supported in
     // build tests.
-    // @see \Drupal\automatic_updates\Validator\AutomatedCronDisabledValidator
+    // @see \Drupal\Tests\automatic_updates\Build\CoreUpdateTest::testAutomatedCron
     $page = $this->getMink()->getSession()->getPage();
     $this->visit('/admin/modules/uninstall');
     $page->checkField("uninstall[automated_cron]");
diff --git a/tests/src/Functional/AutomatedCronDisabledValidatorTest.php b/tests/src/Functional/AutomatedCronDisabledValidatorTest.php
deleted file mode 100644
index 41567a3bc7efe3a015bc45b486f45838c146e3ec..0000000000000000000000000000000000000000
--- a/tests/src/Functional/AutomatedCronDisabledValidatorTest.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Functional;
-
-use Drupal\automatic_updates\CronUpdateStage;
-
-/**
- * Tests that updates are not run by Automated Cron.
- *
- * @covers \Drupal\automatic_updates\Validator\AutomatedCronDisabledValidator
- * @group automatic_updates
- */
-class AutomatedCronDisabledValidatorTest extends AutomaticUpdatesFunctionalTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $defaultTheme = 'stark';
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['dblog', 'automated_cron'];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-    $this->drupalLogin($this->createUser(['access site reports']));
-  }
-
-  /**
-   * Tests that automatic updates are not triggered by Automated Cron.
-   */
-  public function testAutomatedCronUpdate() {
-    // Delete the last cron run time, to ensure that Automated Cron will run.
-    $this->container->get('state')->delete('system.cron_last');
-    $this->config('automatic_updates.settings')
-      ->set('unattended.level', CronUpdateStage::ALL)
-      ->save();
-
-    $this->drupalGet('user');
-    // The `drupalGet()` will not wait for the HTTP kernel to terminate (i.e.,
-    // the `KernelEvents::TERMINATE` event) to complete. Although this event
-    // will likely already be completed, wait 1 second to avoid random test
-    // failures.
-    sleep(1);
-    $this->drupalGet('admin/reports/dblog');
-    $this->assertSession()->elementAttributeContains('css', 'a[title^="Unattended"]', 'title', 'Unattended automatic updates were triggered by Automated Cron, which is not supported. No update was performed. See the status report for more information.');
-  }
-
-}
diff --git a/tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php b/tests/src/Functional/StatusCheckFailureEmailTest.php
similarity index 79%
rename from tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php
rename to tests/src/Functional/StatusCheckFailureEmailTest.php
index 9a572a5fbe5f8d00400f0b800f765cf38557176b..aec365beb028ae35acf7ab195311ee422f849e8c 100644
--- a/tests/src/Kernel/StatusCheck/StatusCheckFailureEmailTest.php
+++ b/tests/src/Functional/StatusCheckFailureEmailTest.php
@@ -2,7 +2,7 @@
 
 declare(strict_types = 1);
 
-namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
+namespace Drupal\Tests\automatic_updates\Functional;
 
 use Drupal\automatic_updates\CronUpdateStage;
 use Drupal\automatic_updates\StatusCheckMailer;
@@ -11,8 +11,10 @@ use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
 use Drupal\Core\Url;
 use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\system\SystemManager;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
 use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;
+use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
+use Drupal\Tests\Traits\Core\CronRunTrait;
+use Drush\TestTraits\DrushTestTrait;
 
 /**
  * Tests status check failure notification emails during cron runs.
@@ -21,9 +23,17 @@ use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;
  * @covers \Drupal\automatic_updates\StatusCheckMailer
  * @internal
  */
-class StatusCheckFailureEmailTest extends AutomaticUpdatesKernelTestBase {
+class StatusCheckFailureEmailTest extends AutomaticUpdatesFunctionalTestBase {
 
+  use CronRunTrait;
   use EmailNotificationsTestTrait;
+  use DrushTestTrait;
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
 
   /**
    * {@inheritdoc}
@@ -48,11 +58,7 @@ class StatusCheckFailureEmailTest extends AutomaticUpdatesKernelTestBase {
   protected function setUp(): void {
     parent::setUp();
     // Simulate that we're already fully up to date.
-    $this->setCoreVersion('9.8.1');
-    $this->installEntitySchema('user');
-    $this->installSchema('user', ['users_data']);
-
-    $this->installConfig('automatic_updates');
+    $this->mockActiveCoreVersion('9.8.1');
     // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
     $this->config('automatic_updates.settings')
       ->set('unattended.level', CronUpdateStage::SECURITY)
@@ -68,21 +74,10 @@ class StatusCheckFailureEmailTest extends AutomaticUpdatesKernelTestBase {
     $this->config('update.settings')
       ->set('check.interval_days', 30)
       ->save();
-  }
 
-  /**
-   * Runs cron, simulating a two-hour interval since the previous run.
-   *
-   * We need to simulate that at least an hour has passed since the previous
-   * run, so that our cron hook will run status checks again.
-   *
-   * @see automatic_updates_cron()
-   */
-  private function runCron(): void {
-    $offset = $this->cronRunCount * 2;
-    $this->cronRunCount++;
-    TestTime::setFakeTimeByOffset("+$offset hours");
-    $this->container->get('cron')->run();
+    $this->config('automatic_updates.settings')
+      ->set('unattended.method', 'console')
+      ->save();
   }
 
   /**
@@ -107,12 +102,18 @@ class StatusCheckFailureEmailTest extends AutomaticUpdatesKernelTestBase {
 
     $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
     TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
 
     $url = Url::fromRoute('system.status')
       ->setAbsolute()
       ->toString();
 
+    // @todo For some reason Drush does not use the correct base path when
+    //   creating the emails. Remove this workaround when the Drush dependency
+    //   is removed in https://drupal.org/i/3360485.
+    $base = $this->buildUrl('');
+    $url = str_replace($base, 'http://default/', $url);
+
     $expected_body = <<<END
 Your site has failed some readiness checks for automatic updates and may not be able to receive automatic updates until further action is taken. Visit $url for more information.
 END;
@@ -123,13 +124,13 @@ END;
     $recipient_count = count($this->emailRecipients);
     $this->assertGreaterThan(0, $recipient_count);
     $sent_messages_count = $recipient_count;
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If a different error is flagged, they should be e-mailed again.
     $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
     TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
 
@@ -141,28 +142,28 @@ END;
       $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
     ];
     TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If only a warning is flagged, they should not be e-mailed again because
     // we ignore warnings by default.
     $warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING);
     TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If we stop ignoring warnings, they should be e-mailed again because we
     // clear the stored results if the relevant configuration is changed.
     $config = $this->config('automatic_updates.settings');
     $config->set('status_check_mail', StatusCheckMailer::ALL)->save();
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If we flag a different warning, they should be e-mailed again.
     $warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING);
     TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
 
@@ -173,7 +174,7 @@ END;
       $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
     ];
     TestSubscriber1::setTestResult($warnings, StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
 
@@ -184,7 +185,7 @@ END;
       $this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
     ];
     TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
 
@@ -193,7 +194,7 @@ END;
     // different order.
     $results = array_reverse($results);
     TestSubscriber1::setTestResult($results, StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If we disable notifications entirely, they should not be e-mailed even
@@ -201,7 +202,7 @@ END;
     $config->set('status_check_mail', StatusCheckMailer::DISABLED)->save();
     $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
     TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If we re-enable notifications and go back to ignoring warnings, they
@@ -209,7 +210,7 @@ END;
     $config->set('status_check_mail', StatusCheckMailer::ERRORS_ONLY)->save();
     $warning = $this->createValidationResult(SystemManager::REQUIREMENT_WARNING);
     TestSubscriber1::setTestResult([$warning], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If we disable unattended updates entirely and flag a new error, they
@@ -217,15 +218,31 @@ END;
     $config->set('unattended.level', CronUpdateStage::DISABLED)->save();
     $error = $this->createValidationResult(SystemManager::REQUIREMENT_ERROR);
     TestSubscriber1::setTestResult([$error], StatusCheckEvent::class);
-    $this->runCron();
+    $this->runConsoleUpdateCommand();
     $this->assertSentMessagesCount($sent_messages_count);
 
     // If we re-enable unattended updates, they should be emailed again, even if
     // the results haven't changed.
-    $config->set('unattended.level', CronUpdateStage::ALL)->save();
-    $this->runCron();
+    $config->set('unattended.level', CronUpdateStage::SECURITY)->save();
+    $this->runConsoleUpdateCommand();
     $sent_messages_count += $recipient_count;
     $this->assertSentMessagesCount($sent_messages_count);
   }
 
+  /**
+   * Runs the console update command which will trigger the status checks.
+   */
+  private function runConsoleUpdateCommand(): void {
+    static $total_delay = 0;
+    // Status checks don't run more than once an hour, so pretend that 61
+    // minutes have elapsed since the last run.
+    $total_delay += 61;
+    TestTime::setFakeTimeByOffset("+$total_delay minutes");
+    $this->drush('auto-update');
+    // Since the terminal command that sent the emails doesn't use the same
+    // container as this test we need to reset the state cache where to get the
+    // test data about the sent emails.
+    $this->container->get('state')->resetCache();
+  }
+
 }
diff --git a/tests/src/Functional/StatusCheckTest.php b/tests/src/Functional/StatusCheckTest.php
index e9391488904c2811e1a1a0537b3b8131c18c0dd4..86225b5e280c76af9f395c82d80bf06b93e36491 100644
--- a/tests/src/Functional/StatusCheckTest.php
+++ b/tests/src/Functional/StatusCheckTest.php
@@ -262,7 +262,21 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
     $this->drupalGet('admin/reports/status');
     $this->assertNoErrors();
     $this->drupalGet(Url::fromRoute($admin_route));
-    $assert->elementNotExists('css', $messages_section_selector);
+
+    // @todo When removing the Drush dependency we should only need assert that
+    //   no status messages appear at all in https://drupal.org/i/3360485.
+    $assert_no_status_check_messages = function () use ($admin_route, $messages_section_selector, $assert) {
+      // @todo Remove in https://drupal.org/i/3360485.
+      if ($admin_route === 'update.settings') {
+        // Ensure there is warning about missing Drush and no other errors or
+        // warnings.
+        $assert->elementTextEquals('css', $messages_section_selector, 'Warning message Drush is required for unattended updates.');
+      }
+      else {
+        $assert->statusMessageNotExists();
+      }
+    };
+    $assert_no_status_check_messages();
 
     // Confirm a user without the permission to run status checks does not have
     // a link to run the checks when the checks need to be run again.
@@ -274,7 +288,7 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
     // A user without the permission to run the checkers will not see a message
     // on other pages if the checkers need to be run again.
     $this->drupalGet(Url::fromRoute($admin_route));
-    $assert->elementNotExists('css', $messages_section_selector);
+    $assert_no_status_check_messages();
 
     // Confirm that a user with the correct permission can also run the checkers
     // on another admin page.
diff --git a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
index c85b710d1ee51d3e525759d0a673490632f69118..eecd31e087043e109b1bdb4d85b5058e2a65b0fd 100644
--- a/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
+++ b/tests/src/Kernel/AutomaticUpdatesKernelTestBase.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\Tests\automatic_updates\Kernel;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
 use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
@@ -84,6 +85,7 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa
     // Use the test-only implementations of the regular and cron update stages.
     $overrides = [
       'automatic_updates.cron_update_stage' => TestCronUpdateStage::class,
+      DrushUpdateStage::class => TestDrushUpdateStage::class,
     ];
     foreach ($overrides as $service_id => $class) {
       if ($container->hasDefinition($service_id)) {
@@ -92,6 +94,13 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa
     }
   }
 
+  /**
+   * Performs an update using the console update stage directly.
+   */
+  protected function runConsoleUpdateStage(): void {
+    $this->container->get(DrushUpdateStage::class)->performUpdate();
+  }
+
 }
 
 /**
@@ -99,6 +108,26 @@ abstract class AutomaticUpdatesKernelTestBase extends PackageManagerKernelTestBa
  */
 class TestCronUpdateStage extends CronUpdateStage {
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function runTerminalUpdateCommand(): never {
+    // Invoking the terminal command will not work and is not necessary in
+    // kernel tests. Throw an exception for tests that need to assert that
+    // the terminal command would have been invoked.
+    // @todo Determine if the terminal command can be run in kernel tests when
+    //   the Drush dependency is removed in https://drupal.org/i/3360485.
+    //   Drush\TestTraits\DrushTestTrait only works with functional tests.
+    throw new \BadMethodCallException(static::class);
+  }
+
+}
+
+/**
+ * A test-only version of the drush update stage to override and expose internals.
+ */
+class TestDrushUpdateStage extends DrushUpdateStage {
+
   /**
    * {@inheritdoc}
    */
@@ -123,9 +152,7 @@ class TestCronUpdateStage extends CronUpdateStage {
   /**
    * {@inheritdoc}
    */
-  protected function triggerPostApply(string $stage_id, string $start_version, string $target_version): void {
-    // Subrequests don't work in kernel tests, so just call the post-apply
-    // handler directly.
+  protected function triggerPostApply(string $stage_id, string $start_version, string $target_version, bool $is_from_web): void {
     $this->handlePostApply($stage_id, $start_version, $target_version);
   }
 
diff --git a/tests/src/Kernel/CronUpdateStageTest.php b/tests/src/Kernel/CronUpdateStageTest.php
index b7c57439b1285959d7d60e067c843195241c097f..1b8c625a5a77a7ae798b836c2369c6001ff297e4 100644
--- a/tests/src/Kernel/CronUpdateStageTest.php
+++ b/tests/src/Kernel/CronUpdateStageTest.php
@@ -5,45 +5,14 @@ declare(strict_types = 1);
 namespace Drupal\Tests\automatic_updates\Kernel;
 
 use Drupal\automatic_updates\CronUpdateStage;
-use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\Core\Url;
-use Drupal\package_manager\Event\PostApplyEvent;
-use Drupal\package_manager\Event\PostCreateEvent;
-use Drupal\package_manager\Event\PostDestroyEvent;
-use Drupal\package_manager\Event\PostRequireEvent;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\PreDestroyEvent;
-use Drupal\package_manager\Event\PreRequireEvent;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Exception\StageEventException;
-use Drupal\package_manager\Exception\StageOwnershipException;
-use Drupal\package_manager\ValidationResult;
-use Drupal\package_manager_bypass\LoggingCommitter;
-use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;
-use Drupal\Tests\package_manager\Kernel\TestStage;
-use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
-use Drupal\Tests\user\Traits\UserCreationTrait;
-use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException;
-use PhpTuf\ComposerStager\API\Exception\PreconditionException;
-use PhpTuf\ComposerStager\API\Precondition\Service\PreconditionInterface;
-use PhpTuf\ComposerStager\Internal\Translation\Value\TranslatableMessage;
-use Prophecy\Argument;
-use ColinODell\PsrTestLogger\TestLogger;
-use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 
 /**
- * @covers \Drupal\automatic_updates\CronUpdateStage
+ * @coversDefaultClass  \Drupal\automatic_updates\CronUpdateStage
  * @group automatic_updates
  * @internal
  */
 class CronUpdateStageTest extends AutomaticUpdatesKernelTestBase {
 
-  use EmailNotificationsTestTrait;
-  use PackageManagerBypassTestTrait;
-  use UserCreationTrait;
-
   /**
    * {@inheritdoc}
    */
@@ -55,705 +24,73 @@ class CronUpdateStageTest extends AutomaticUpdatesKernelTestBase {
   ];
 
   /**
-   * The test logger.
-   *
-   * @var \ColinODell\PsrTestLogger\TestLogger
-   */
-  private $logger;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $this->logger = new TestLogger();
-    $this->container->get('logger.factory')
-      ->get('automatic_updates')
-      ->addLogger($this->logger);
-    $this->installEntitySchema('user');
-    $this->installSchema('user', ['users_data']);
-
-    $this->setUpEmailRecipients();
-    $this->assertRegularCronRun(FALSE);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function register(ContainerBuilder $container) {
-    parent::register($container);
-
-    // Since this test dynamically adds additional loggers to certain channels,
-    // we need to ensure they will persist even if the container is rebuilt when
-    // staged changes are applied.
-    // @see ::testStageDestroyedOnError()
-    $container->getDefinition('logger.factory')->addTag('persist');
-  }
-
-  /**
-   * Data provider for testUpdateStageCalled().
-   *
-   * @return mixed[][]
-   *   The test cases.
-   */
-  public function providerUpdateStageCalled(): array {
-    $fixture_dir = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
-
-    return [
-      'disabled, normal release' => [
-        CronUpdateStage::DISABLED,
-        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
-        FALSE,
-      ],
-      'disabled, security release' => [
-        CronUpdateStage::DISABLED,
-        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
-        FALSE,
-      ],
-      'security only, security release' => [
-        CronUpdateStage::SECURITY,
-        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
-        TRUE,
-      ],
-      'security only, normal release' => [
-        CronUpdateStage::SECURITY,
-        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
-        FALSE,
-      ],
-      'enabled, normal release' => [
-        CronUpdateStage::ALL,
-        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
-        TRUE,
-      ],
-      'enabled, security release' => [
-        CronUpdateStage::ALL,
-        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
-        TRUE,
-      ],
-    ];
-  }
-
-  /**
-   * Tests that the cron handler calls the update stage as expected.
+   * Tests that hook_cron implementations are always invoked.
    *
-   * @param string $setting
-   *   Whether automatic updates should be enabled during cron. Possible values
-   *   are 'disable', 'security', and 'patch'.
-   * @param array $release_data
-   *   If automatic updates are enabled, the path of the fake release metadata
-   *   that should be served when fetching information on available updates,
-   *   keyed by project name.
-   * @param bool $will_update
-   *   Whether an update should be performed, given the previous two arguments.
-   *
-   * @dataProvider providerUpdateStageCalled
+   * @covers ::run
    */
-  public function testUpdateStageCalled(string $setting, array $release_data, bool $will_update): void {
-    $version = strpos($release_data['drupal'], '9.8.2') ? '9.8.2' : '9.8.1';
-    if ($will_update) {
-      $this->getStageFixtureManipulator()->setCorePackageVersion($version);
-    }
-    // Our form alter does not refresh information on available updates, so
-    // ensure that the appropriate update data is loaded beforehand.
-    $this->setReleaseMetadata($release_data);
-    $this->setCoreVersion('9.8.0');
-    update_get_available(TRUE);
-    $this->config('automatic_updates.settings')
-      ->set('unattended.level', $setting)
-      ->save();
+  public function testHookCronInvoked(): void {
+    // Delete the state value set when cron runs to ensure next asserts start
+    // from a good state.
+    // @see \common_test_cron_helper_cron()
+    $this->container->get('state')->delete('common_test.cron');
 
-    // Since we're just trying to ensure that all of Package Manager's services
-    // are called as expected, disable validation by replacing the event
-    // dispatcher with a dummy version.
-    $event_dispatcher = $this->prophesize(EventDispatcherInterface::class);
-    $event_dispatcher->dispatch(Argument::type('object'))->willReturnArgument(0);
-    $this->container->set('event_dispatcher', $event_dispatcher->reveal());
+    // Undo override of the 'serverApi' property from the parent test class.
+    // @see \Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase::setUp
+    $property = new \ReflectionProperty(CronUpdateStage::class, 'serverApi');
+    $property->setValue(NULL, 'cli');
+    $this->assertTrue(CronUpdateStage::isCommandLine());
 
-    // Run cron and ensure that Package Manager's services were called or
-    // bypassed depending on configuration.
+    // Since we're at the command line, the terminal command should not be
+    // invoked. Since we are in a kernel test, there would be an exception
+    // if that happened.
+    // @see \Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage::runTerminalUpdateCommand
     $this->container->get('cron')->run();
-
-    $will_update = (int) $will_update;
-    $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments());
-    // If updates happen, there will be at least two calls to the stager: one
-    // to change the runtime constraints in composer.json, and another to
-    // actually update the installed dependencies. If there are any core
-    // dev requirements (such as `drupal/core-dev`), the stager will also be
-    // called to update the dev constraints in composer.json.
-    $this->assertGreaterThanOrEqual($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
-    $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments());
-  }
-
-  /**
-   * Data provider for testStageDestroyedOnError().
-   *
-   * @return string[][]
-   *   The test cases.
-   */
-  public function providerStageDestroyedOnError(): array {
-    return [
-      'pre-create exception' => [
-        PreCreateEvent::class,
-        'Exception',
-      ],
-      'post-create exception' => [
-        PostCreateEvent::class,
-        'Exception',
-      ],
-      'pre-require exception' => [
-        PreRequireEvent::class,
-        'Exception',
-      ],
-      'post-require exception' => [
-        PostRequireEvent::class,
-        'Exception',
-      ],
-      'pre-apply exception' => [
-        PreApplyEvent::class,
-        'Exception',
-      ],
-      'post-apply exception' => [
-        PostApplyEvent::class,
-        'Exception',
-      ],
-      'pre-destroy exception' => [
-        PreDestroyEvent::class,
-        'Exception',
-      ],
-      'post-destroy exception' => [
-        PostDestroyEvent::class,
-        'Exception',
-      ],
-      // Only pre-operation events can add validation results.
-      // @see \Drupal\package_manager\Event\PreOperationStageEvent
-      // @see \Drupal\package_manager\Stage::dispatch()
-      'pre-create validation error' => [
-        PreCreateEvent::class,
-        StageEventException::class,
-      ],
-      'pre-require validation error' => [
-        PreRequireEvent::class,
-        StageEventException::class,
-      ],
-      'pre-apply validation error' => [
-        PreApplyEvent::class,
-        StageEventException::class,
-      ],
-      'pre-destroy validation error' => [
-        PreDestroyEvent::class,
-        StageEventException::class,
-      ],
-    ];
-  }
-
-  /**
-   * Tests that the stage is destroyed if an error occurs during a cron update.
-   *
-   * @param string $event_class
-   *   The stage life cycle event which should raise an error.
-   * @param string $exception_class
-   *   The class of exception that will be thrown when the given event is fired.
-   *
-   * @dataProvider providerStageDestroyedOnError
-   */
-  public function testStageDestroyedOnError(string $event_class, string $exception_class): void {
-    // If the failure happens before the stage is even created, the stage
-    // fixture need not be manipulated.
-    if ($event_class !== PreCreateEvent::class) {
-      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-    }
-    $this->installConfig('automatic_updates');
-    // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
+    // Even though the terminal command was not invoked, hook_cron
+    // implementations should have been run.
+    $this->assertCronRan();
+
+    // If we are on the web but the method is set to 'console' the terminal
+    // command should not be invoked.
+    $property->setValue(NULL, 'cgi-fcgi');
+    $this->assertFalse(CronUpdateStage::isCommandLine());
     $this->config('automatic_updates.settings')
-      ->set('unattended.level', CronUpdateStage::SECURITY)
+      ->set('unattended.method', 'console')
       ->save();
-    // Ensure that there is a security release to which we should update.
-    $this->setReleaseMetadata([
-      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
-    ]);
-
-    // If the pre- or post-destroy events throw an exception, it will not be
-    // caught by the cron update stage, but it *will* be caught by the main cron
-    // service, which will log it as a cron error that we'll want to check for.
-    $cron_logger = new TestLogger();
-    $this->container->get('logger.factory')
-      ->get('cron')
-      ->addLogger($cron_logger);
-
-    /** @var \Drupal\automatic_updates\CronUpdateStage $stage */
-    $stage = $this->container->get(CronUpdateStage::class);
-
-    // When the event specified by $event_class is dispatched, either throw an
-    // exception directly from the event subscriber, or prepare a
-    // StageEventException which will format the validation errors its own way.
-    if ($exception_class === StageEventException::class) {
-      $error = ValidationResult::createError([
-        t('Destroy the stage!'),
-      ]);
-      $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $stage);
-      TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
-    }
-    else {
-      /** @var \Throwable $exception */
-      $exception = new $exception_class('Destroy the stage!');
-      TestSubscriber1::setException($exception, $event_class);
-    }
-    $expected_log_message = $exception->getMessage();
-
-    // Ensure that nothing has been logged yet.
-    $this->assertEmpty($cron_logger->records);
-    $this->assertEmpty($this->logger->records);
-
-    $this->assertTrue($stage->isAvailable());
     $this->container->get('cron')->run();
+    $this->assertCronRan();
 
-    $logged_by_stage = $this->logger->hasRecord($expected_log_message, (string) RfcLogLevel::ERROR);
-    // To check if the exception was logged by the main cron service, we need
-    // to set up a special predicate, because exceptions logged by cron are
-    // always formatted in a particular way that we don't control. But the
-    // original exception object is stored with the log entry, so we look for
-    // that and confirm that its message is the same.
-    // @see watchdog_exception()
-    $predicate = function (array $record) use ($exception): bool {
-      if (isset($record['context']['exception'])) {
-        return $record['context']['exception']->getMessage() === $exception->getMessage();
-      }
-      return FALSE;
-    };
-    $logged_by_cron = $cron_logger->hasRecordThatPasses($predicate, (string) RfcLogLevel::ERROR);
-
-    // If a pre-destroy event flags a validation error, it's handled like any
-    // other event (logged by the cron update stage, but not the main cron
-    // service). But if a pre- or post-destroy event throws an exception, the
-    // cron update stage won't try to catch it. Instead, it will be caught and
-    // logged by the main cron service.
-    if ($event_class === PreDestroyEvent::class || $event_class === PostDestroyEvent::class) {
-      // If the pre-destroy event throws an exception or flags a validation
-      // error, the stage won't be destroyed. But, once the post-destroy event
-      // is fired, the stage should be fully destroyed and marked as available.
-      $this->assertSame($event_class === PostDestroyEvent::class, $stage->isAvailable());
-    }
-    else {
-      $this->assertTrue($stage->isAvailable());
-    }
-    $this->assertTrue($logged_by_stage);
-    $this->assertFalse($logged_by_cron);
-  }
-
-  /**
-   * Tests stage is destroyed if not available and site is on insecure version.
-   */
-  public function testStageDestroyedIfNotAvailable(): void {
-    $stage = $this->createStage();
-    $stage_id = $stage->create();
-    $original_stage_directory = $stage->getStageDirectory();
-    $this->assertDirectoryExists($original_stage_directory);
-
-    $listener = function (PostRequireEvent $event) use (&$cron_stage_dir, $original_stage_directory): void {
-      $this->assertDirectoryDoesNotExist($original_stage_directory);
-      $cron_stage_dir = $this->container->get('package_manager.stager')->getInvocationArguments()[0][1]->resolved();
-      $this->assertSame($event->stage->getStageDirectory(), $cron_stage_dir);
-      $this->assertDirectoryExists($cron_stage_dir);
-    };
-    $this->addEventTestListener($listener, PostRequireEvent::class);
-
-    $this->container->get('cron')->run();
-    $this->assertIsString($cron_stage_dir);
-    $this->assertNotEquals($original_stage_directory, $cron_stage_dir);
-    $this->assertDirectoryDoesNotExist($cron_stage_dir);
-    $this->assertTrue($this->logger->hasRecord('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.', (string) RfcLogLevel::NOTICE));
-
-    $stage2 = $this->createStage();
-    $stage2->create();
-    $this->expectException(StageOwnershipException::class);
-    $this->expectExceptionMessage('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.');
-    $stage->claim($stage_id);
-  }
-
-  /**
-   * Tests stage is not destroyed if another update is applying.
-   */
-  public function testStageNotDestroyedIfApplying(): void {
+    // If we are on the web and method settings is 'web' the terminal command
+    // should be invoked.
     $this->config('automatic_updates.settings')
-      ->set('unattended.level', CronUpdateStage::ALL)
+      ->set('unattended.method', 'web')
       ->save();
-    $this->setReleaseMetadata([
-      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
-    ]);
-    $this->setCoreVersion('9.8.0');
-    $stage = $this->createStage();
-    $stage->create();
-    $stage->require(['drupal/core:9.8.1']);
-    $stop_error = t('Stopping stage from applying');
-
-    // Add a PreApplyEvent event listener so we can attempt to run cron when
-    // another stage is applying.
-    $this->addEventTestListener(function (PreApplyEvent $event) use ($stop_error) {
-      // Ensure the stage that is applying the operation is not the cron
-      // update stage.
-      $this->assertInstanceOf(TestStage::class, $event->stage);
-      $this->container->get('cron')->run();
-      // We do not actually want to apply this operation it was just invoked to
-      // allow cron to be  attempted.
-      $event->addError([$stop_error]);
-    });
-
-    try {
-      $stage->apply();
-      $this->fail('Expected update to fail');
-    }
-    catch (StageEventException $exception) {
-      $this->assertExpectedResultsFromException([ValidationResult::createError([$stop_error])], $exception);
-    }
-
-    $this->assertTrue($this->logger->hasRecord("Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href=\"%url\">update form</a>.", (string) RfcLogLevel::NOTICE));
-    $this->assertUpdateStagedTimes(1);
-  }
-
-  /**
-   * Tests stage is not destroyed if not available and site is on secure version.
-   */
-  public function testStageNotDestroyedIfSecure(): void {
-    $this->config('automatic_updates.settings')
-      ->set('unattended.level', CronUpdateStage::ALL)
-      ->save();
-    $this->setReleaseMetadata([
-      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml",
-    ]);
-    $this->setCoreVersion('9.8.1');
-    $stage = $this->createStage();
-    $stage->create();
-    $stage->require(['drupal/random']);
-    $this->assertUpdateStagedTimes(1);
-
-    // Trigger CronUpdateStage, the above should cause it to detect a stage that
-    // is applying.
-    $this->container->get('cron')->run();
-
-    $this->assertTrue($this->logger->hasRecord('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.', (string) RfcLogLevel::NOTICE));
-    $this->assertUpdateStagedTimes(1);
-  }
-
-  /**
-   * Tests that CronUpdateStage::begin() unconditionally throws an exception.
-   */
-  public function testBeginThrowsException(): void {
-    $this->expectExceptionMessage(CronUpdateStage::class . '::begin() cannot be called directly.');
-    $this->container->get(CronUpdateStage::class)
-      ->begin(['drupal' => '9.8.1']);
-  }
-
-  /**
-   * Tests that email is sent when an unattended update succeeds.
-   */
-  public function testEmailOnSuccess(): void {
-    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-    $this->container->get('cron')->run();
-
-    // Ensure we sent a success message to all recipients.
-    $expected_body = <<<END
-Congratulations!
-
-Drupal core was automatically updated from 9.8.0 to 9.8.1.
-
-This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
-
-If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
-END;
-    $this->assertMessagesSent("Drupal core was successfully updated", $expected_body);
-    $this->assertRegularCronRun(FALSE);
-  }
-
-  /**
-   * Tests that a success email is sent even when post-apply tasks fail.
-   */
-  public function testEmailSentIfPostApplyFails(): void {
-    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-
-    $exception = new \Exception('Error during running post-apply tasks!');
-    TestSubscriber1::setException($exception, PostApplyEvent::class);
-
-    $this->container->get('cron')->run();
-    $this->assertRegularCronRun(FALSE);
-    $this->assertTrue($this->logger->hasRecord($exception->getMessage(), (string) RfcLogLevel::ERROR));
-
-    // Ensure we sent a success email to all recipients, even though post-apply
-    // tasks failed.
-    $expected_body = <<<END
-Congratulations!
-
-Drupal core was automatically updated from 9.8.0 to 9.8.1.
-
-This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
-
-If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
-END;
-    $this->assertMessagesSent("Drupal core was successfully updated", $expected_body);
-  }
-
-  /**
-   * Tests that regular cron runs if not update is available.
-   */
-  public function testNoUpdateAvailable(): void {
-    $this->setCoreVersion('9.8.2');
-    $this->container->get('cron')->run();
-    $this->assertRegularCronRun(TRUE);
-  }
-
-  /**
-   * Tests that regular cron does not run if an update is started.
-   *
-   * @param string $event_exception_class
-   *   The event in which to throw the exception.
-   *
-   * @dataProvider providerRegularCronRuns
-   */
-  public function testRegularCronRuns(string $event_exception_class): void {
-    $this->addEventTestListener(
-      function (): void {
-        throw new \Exception('😜');
-      },
-      $event_exception_class
-    );
     try {
       $this->container->get('cron')->run();
+      $this->fail('Expected an exception when running updates via cron.');
     }
-    catch (\Exception $e) {
-      if ($event_exception_class !== PostDestroyEvent::class && $event_exception_class !== PreDestroyEvent::class) {
-        // No other events should result in an exception.
-        throw $e;
-      }
-      $this->assertSame('😜', $e->getMessage());
-    }
-    $this->assertRegularCronRun($event_exception_class === PreCreateEvent::class);
-  }
-
-  /**
-   * Data provider for testStageDestroyedOnError().
-   *
-   * @return string[][]
-   *   The test cases.
-   */
-  public function providerRegularCronRuns(): array {
-    return [
-      'pre-create exception' => [PreCreateEvent::class],
-      'post-create exception' => [PostCreateEvent::class],
-      'pre-require exception' => [PreRequireEvent::class],
-      'post-require exception' => [PostRequireEvent::class],
-      'pre-apply exception' => [PreApplyEvent::class],
-      'post-apply exception' => [PostApplyEvent::class],
-      'pre-destroy exception' => [PreDestroyEvent::class],
-      'post-destroy exception' => [PostDestroyEvent::class],
-    ];
-  }
-
-  /**
-   * Data provider for ::testEmailOnFailure().
-   *
-   * @return string[][]
-   *   The test cases.
-   */
-  public function providerEmailOnFailure(): array {
-    return [
-      'pre-create' => [
-        PreCreateEvent::class,
-      ],
-      'pre-require' => [
-        PreRequireEvent::class,
-      ],
-      'pre-apply' => [
-        PreApplyEvent::class,
-      ],
-    ];
-  }
-
-  /**
-   * Tests the failure e-mail when an unattended non-security update fails.
-   *
-   * @param string $event_class
-   *   The event class that should trigger the failure.
-   *
-   * @dataProvider providerEmailOnFailure
-   */
-  public function testNonUrgentFailureEmail(string $event_class): void {
-    // If the failure happens before the stage is even created, the stage
-    // fixture need not be manipulated.
-    if ($event_class !== PreCreateEvent::class) {
-      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.2');
+    catch (\BadMethodCallException $e) {
+      $this->assertSame(TestCronUpdateStage::class, $e->getMessage());
     }
-    $this->setReleaseMetadata([
-      'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml',
-    ]);
-    $this->config('automatic_updates.settings')
-      ->set('unattended.level', CronUpdateStage::ALL)
-      ->save();
-
-    $error = ValidationResult::createError([
-      t('Error while updating!'),
-    ]);
-    $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get(CronUpdateStage::class));
-    TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
-
-    $this->container->get('cron')->run();
-
-    $url = Url::fromRoute('update.report_update')
-      ->setAbsolute()
-      ->toString();
-
-    $expected_body = <<<END
-Drupal core failed to update automatically from 9.8.0 to 9.8.2. The following error was logged:
-
-{$exception->getMessage()}
-
-No immediate action is needed, but it is recommended that you visit $url to perform the update, or at least check that everything still looks good.
-
-This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
-
-If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
-END;
-    $this->assertMessagesSent("Drupal core update failed", $expected_body);
+    // Even though the terminal command threw exception hook_cron
+    // implementations should have been invoked before this.
+    $this->assertCronRan();
   }
 
   /**
-   * Tests the failure e-mail when an unattended security update fails.
-   *
-   * @param string $event_class
-   *   The event class that should trigger the failure.
+   * Asserts hook_cron implementations were invoked.
    *
-   * @dataProvider providerEmailOnFailure
+   * @see \common_test_cron_helper_cron()
    */
-  public function testSecurityUpdateFailureEmail(string $event_class): void {
-    // If the failure happens before the stage is even created, the stage
-    // fixture need not be manipulated.
-    if ($event_class !== PreCreateEvent::class) {
-      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-    }
-
-    $error = ValidationResult::createError([
-      t('Error while updating!'),
-    ]);
-    TestSubscriber1::setTestResult([$error], $event_class);
-    $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get(CronUpdateStage::class));
-
-    $this->container->get('cron')->run();
-
-    $url = Url::fromRoute('update.report_update')
-      ->setAbsolute()
-      ->toString();
-
-    $expected_body = <<<END
-Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged:
-
-{$exception->getMessage()}
-
-Your site is running an insecure version of Drupal and should be updated as soon as possible. Visit $url to perform the update.
-
-This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
-
-If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
-END;
-    $this->assertMessagesSent("URGENT: Drupal core update failed", $expected_body);
-  }
-
-  /**
-   * Tests the failure e-mail when an unattended update fails to apply.
-   */
-  public function testApplyFailureEmail(): void {
-    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-    $error = new \LogicException('I drink your milkshake!');
-    LoggingCommitter::setException($error);
-
-    $this->container->get('cron')->run();
-    $expected_body = <<<END
-Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged:
-
-Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup. Caused by LogicException, with this message: {$error->getMessage()}
-
-This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
-
-If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
-END;
-    $this->assertMessagesSent('URGENT: Drupal core update failed', $expected_body);
-  }
-
-  /**
-   * Tests that setLogger is called on the cron update stage service.
-   */
-  public function testLoggerIsSetByContainer(): void {
-    $stage_method_calls = $this->container->getDefinition('automatic_updates.cron_update_stage')->getMethodCalls();
-    $this->assertSame('setLogger', $stage_method_calls[0][0]);
-  }
-
-  /**
-   * Tests that maintenance mode is on when staged changes are applied.
-   */
-  public function testMaintenanceModeIsOnDuringApply(): void {
-    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-
-    /** @var \Drupal\Core\State\StateInterface $state */
-    $state = $this->container->get('state');
-    // Before the update begins, we should have no indication that we have ever
-    // been in maintenance mode (i.e., the value in state is NULL).
-    $this->assertNull($state->get('system.maintenance_mode'));
-    $this->container->get('cron')->run();
-    $state->resetCache();
-    // @see \Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage::apply()
-    $this->assertTrue($this->logger->hasRecord('Unattended update was applied in maintenance mode.', RfcLogLevel::INFO));
-    // @see \Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage::postApply()
-    $this->assertTrue($this->logger->hasRecord('postApply() was called in maintenance mode.', RfcLogLevel::INFO));
-    // During post-apply, maintenance mode should have been explicitly turned
-    // off (i.e., set to FALSE).
-    $this->assertFalse($state->get('system.maintenance_mode'));
-  }
-
-  /**
-   * Data provider for ::testMaintenanceModeAffectedByException().
-   *
-   * @return array[]
-   *   The test cases.
-   */
-  public function providerMaintenanceModeAffectedByException(): array {
-    return [
-      [InvalidArgumentException::class, FALSE],
-      [PreconditionException::class, FALSE],
-      [\Exception::class, TRUE],
-    ];
-  }
-
-  /**
-   * Tests that an exception during apply may keep the site in maintenance mode.
-   *
-   * @param string $exception_class
-   *   The class of the exception that should be thrown by the committer.
-   * @param bool $will_be_in_maintenance_mode
-   *   Whether or not the site will be in maintenance mode afterward.
-   *
-   * @dataProvider providerMaintenanceModeAffectedByException
-   */
-  public function testMaintenanceModeAffectedByException(string $exception_class, bool $will_be_in_maintenance_mode): void {
-    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-
-    $message = new TranslatableMessage('A fail whale upon your head!');
-    LoggingCommitter::setException(match ($exception_class) {
-      InvalidArgumentException::class =>
-        new InvalidArgumentException($message),
-      PreconditionException::class =>
-        new PreconditionException($this->createMock(PreconditionInterface::class), $message),
-      default =>
-        new $exception_class((string) $message),
-    });
-
-    /** @var \Drupal\Core\State\StateInterface $state */
+  private function assertCronRan(): void {
+    $this->assertTrue(
+      $this->container->get('module_handler')->moduleExists('common_test_cron_helper'),
+      '\Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase::assertCronRan can only be used if common_test_cron_helper is enabled.'
+    );
     $state = $this->container->get('state');
-    $this->assertNull($state->get('system.maintenance_mode'));
-    $this->container->get('cron')->run();
-    $this->assertFalse($this->logger->hasRecord('Unattended update was applied in maintenance mode.', RfcLogLevel::INFO));
-    $this->assertSame($will_be_in_maintenance_mode, $state->get('system.maintenance_mode'));
-  }
-
-  private function assertRegularCronRun(bool $expected_cron_run) {
-    $this->assertSame($expected_cron_run, $this->container->get('state')->get('common_test.cron') === 'success');
+    $this->assertSame('success', $state->get('common_test.cron'));
+    // Delete the value so this function can be called again after the next cron
+    // attempt.
+    $state->delete('common_test.cron');
   }
 
 }
diff --git a/tests/src/Kernel/DrushUpdateStageTest.php b/tests/src/Kernel/DrushUpdateStageTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b7b24c78f33207da3d83c5289b25d37b85b089a9
--- /dev/null
+++ b/tests/src/Kernel/DrushUpdateStageTest.php
@@ -0,0 +1,754 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\automatic_updates\Kernel;
+
+use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
+use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\Url;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PostCreateEvent;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreDestroyEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageEventException;
+use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\package_manager_bypass\LoggingCommitter;
+use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;
+use Drupal\Tests\package_manager\Kernel\TestStage;
+use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException;
+use PhpTuf\ComposerStager\API\Exception\PreconditionException;
+use PhpTuf\ComposerStager\API\Precondition\Service\PreconditionInterface;
+use PhpTuf\ComposerStager\Internal\Translation\Value\TranslatableMessage;
+use ColinODell\PsrTestLogger\TestLogger;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * @covers \Drupal\automatic_updates\DrushUpdateStage
+ * @group automatic_updates
+ * @internal
+ */
+class DrushUpdateStageTest extends AutomaticUpdatesKernelTestBase {
+
+  use EmailNotificationsTestTrait;
+  use PackageManagerBypassTestTrait;
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates',
+    'automatic_updates_test',
+    'user',
+    'common_test_cron_helper',
+  ];
+
+  /**
+   * The test logger.
+   *
+   * @var \ColinODell\PsrTestLogger\TestLogger
+   */
+  private $logger;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->logger = new TestLogger();
+    $this->container->get('logger.factory')
+      ->get('automatic_updates')
+      ->addLogger($this->logger);
+    $this->installEntitySchema('user');
+    $this->installSchema('user', ['users_data']);
+
+    $this->setUpEmailRecipients();
+    $this->assertNoCronRun();
+  }
+
+  /**
+   * Tests that a success email is sent even when post-apply tasks fail.
+   */
+  public function testEmailSentIfPostApplyFails(): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+
+    $exception = new \Exception('Error during running post-apply tasks!');
+    TestSubscriber1::setException($exception, PostApplyEvent::class);
+
+    $this->runConsoleUpdateStage();
+    $this->assertNoCronRun();
+    $this->assertTrue($this->logger->hasRecord($exception->getMessage(), (string) RfcLogLevel::ERROR));
+
+    // Ensure we sent a success email to all recipients, even though post-apply
+    // tasks failed.
+    $expected_body = <<<END
+Congratulations!
+
+Drupal core was automatically updated from 9.8.0 to 9.8.1.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("Drupal core was successfully updated", $expected_body);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // Change container to use database lock backends.
+    $container
+      ->register('lock', 'Drupal\Core\Lock\DatabaseLockBackend')
+      ->addArgument(new Reference('database'));
+
+    // Since this test dynamically adds additional loggers to certain channels,
+    // we need to ensure they will persist even if the container is rebuilt when
+    // staged changes are applied.
+    // @see ::testStageDestroyedOnError()
+    $container->getDefinition('logger.factory')->addTag('persist');
+
+    // Since this test adds arbitrary event listeners that aren't services, we
+    // need to ensure they will persist even if the container is rebuilt when
+    // staged changes are applied.
+    $container->getDefinition('event_dispatcher')->addTag('persist');
+  }
+
+  /**
+   * Data provider for testUpdateStageCalled().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerUpdateStageCalled(): array {
+    $fixture_dir = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
+    return [
+      'disabled, normal release' => [
+        CronUpdateStage::DISABLED,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        FALSE,
+      ],
+      'disabled, security release' => [
+        CronUpdateStage::DISABLED,
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
+        FALSE,
+      ],
+      'security only, security release' => [
+        CronUpdateStage::SECURITY,
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
+        TRUE,
+      ],
+      'security only, normal release' => [
+        CronUpdateStage::SECURITY,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        FALSE,
+      ],
+      'enabled, normal release' => [
+        CronUpdateStage::ALL,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        TRUE,
+      ],
+      'enabled, security release' => [
+        CronUpdateStage::ALL,
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the cron handler calls the update stage as expected.
+   *
+   * @param string $setting
+   *   Whether automatic updates should be enabled during cron. Possible values
+   *   are 'disable', 'security', and 'patch'.
+   * @param array $release_data
+   *   If automatic updates are enabled, the path of the fake release metadata
+   *   that should be served when fetching information on available updates,
+   *   keyed by project name.
+   * @param bool $will_update
+   *   Whether an update should be performed, given the previous two arguments.
+   *
+   * @dataProvider providerUpdateStageCalled
+   */
+  public function testUpdateStageCalled(string $setting, array $release_data, bool $will_update): void {
+    $version = strpos($release_data['drupal'], '9.8.2') ? '9.8.2' : '9.8.1';
+    if ($will_update) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion($version);
+    }
+    // Our form alter does not refresh information on available updates, so
+    // ensure that the appropriate update data is loaded beforehand.
+    $this->setReleaseMetadata($release_data);
+    $this->setCoreVersion('9.8.0');
+    update_get_available(TRUE);
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', $setting)
+      ->save();
+
+    $this->assertCount(0, $this->container->get('package_manager.beginner')->getInvocationArguments());
+    // Run cron and ensure that Package Manager's services were called or
+    // bypassed depending on configuration.
+    $this->runConsoleUpdateStage();
+
+    $will_update = (int) $will_update;
+    $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments());
+    // If updates happen, there will be at least two calls to the stager: one
+    // to change the runtime constraints in composer.json, and another to
+    // actually update the installed dependencies. If there are any core
+    // dev requirements (such as `drupal/core-dev`), the stager will also be
+    // called to update the dev constraints in composer.json.
+    $this->assertGreaterThanOrEqual($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
+    $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments());
+  }
+
+  /**
+   * Data provider for testStageDestroyedOnError().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerStageDestroyedOnError(): array {
+    return [
+      'pre-create exception' => [
+        PreCreateEvent::class,
+        'Exception',
+      ],
+      'post-create exception' => [
+        PostCreateEvent::class,
+        'Exception',
+      ],
+      'pre-require exception' => [
+        PreRequireEvent::class,
+        'Exception',
+      ],
+      'post-require exception' => [
+        PostRequireEvent::class,
+        'Exception',
+      ],
+      'pre-apply exception' => [
+        PreApplyEvent::class,
+        'Exception',
+      ],
+      'post-apply exception' => [
+        PostApplyEvent::class,
+        'Exception',
+      ],
+      'pre-destroy exception' => [
+        PreDestroyEvent::class,
+        'Exception',
+      ],
+      'post-destroy exception' => [
+        PostDestroyEvent::class,
+        'Exception',
+      ],
+      // Only pre-operation events can add validation results.
+      // @see \Drupal\package_manager\Event\PreOperationStageEvent
+      // @see \Drupal\package_manager\Stage::dispatch()
+      'pre-create validation error' => [
+        PreCreateEvent::class,
+        StageEventException::class,
+      ],
+      'pre-require validation error' => [
+        PreRequireEvent::class,
+        StageEventException::class,
+      ],
+      'pre-apply validation error' => [
+        PreApplyEvent::class,
+        StageEventException::class,
+      ],
+      'pre-destroy validation error' => [
+        PreDestroyEvent::class,
+        StageEventException::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the stage is destroyed if an error occurs during a cron update.
+   *
+   * @param string $event_class
+   *   The stage life cycle event which should raise an error.
+   * @param string $exception_class
+   *   The class of exception that will be thrown when the given event is fired.
+   *
+   * @dataProvider providerStageDestroyedOnError
+   */
+  public function testStageDestroyedOnError(string $event_class, string $exception_class): void {
+    // If the failure happens before the stage is even created, the stage
+    // fixture need not be manipulated.
+    if ($event_class !== PreCreateEvent::class) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    }
+    $this->installConfig('automatic_updates');
+    // @todo Remove in https://www.drupal.org/project/automatic_updates/issues/3284443
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::SECURITY)
+      ->save();
+    // Ensure that there is a security release to which we should update.
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
+    ]);
+
+    // If the pre- or post-destroy events throw an exception, it will not be
+    // caught by the cron update stage, but it *will* be caught by the main cron
+    // service, which will log it as a cron error that we'll want to check for.
+    $cron_logger = new TestLogger();
+    $this->container->get('logger.factory')
+      ->get('cron')
+      ->addLogger($cron_logger);
+
+    /** @var \Drupal\automatic_updates\DrushUpdateStage $stage */
+    $stage = $this->container->get(DrushUpdateStage::class);
+
+    // When the event specified by $event_class is dispatched, either throw an
+    // exception directly from the event subscriber, or prepare a
+    // StageEventException which will format the validation errors its own way.
+    if ($exception_class === StageEventException::class) {
+      $error = ValidationResult::createError([
+        t('Destroy the stage!'),
+      ]);
+
+      $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $stage);
+      TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
+    }
+    else {
+      /** @var \Throwable $exception */
+      $exception = new $exception_class('Destroy the stage!');
+      TestSubscriber1::setException($exception, $event_class);
+    }
+
+    $expected_log_message = $exception->getMessage();
+
+    // Ensure that nothing has been logged yet.
+    $this->assertEmpty($cron_logger->records);
+    $this->assertEmpty($this->logger->records);
+
+    $this->assertTrue($stage->isAvailable());
+    $this->runConsoleUpdateStage();
+
+    $logged_by_stage = $this->logger->hasRecord($expected_log_message, (string) RfcLogLevel::ERROR);
+    // To check if the exception was logged by the main cron service, we need
+    // to set up a special predicate, because exceptions logged by cron are
+    // always formatted in a particular way that we don't control. But the
+    // original exception object is stored with the log entry, so we look for
+    // that and confirm that its message is the same.
+    // @see watchdog_exception()
+    $predicate = function (array $record) use ($exception): bool {
+      if (isset($record['context']['exception'])) {
+        return $record['context']['exception']->getMessage() === $exception->getMessage();
+      }
+      return FALSE;
+    };
+
+    $logged_by_cron = $cron_logger->hasRecordThatPasses($predicate, (string) RfcLogLevel::ERROR);
+
+    // If a pre-destroy event flags a validation error, it's handled like any
+    // other event (logged by the cron update stage, but not the main cron
+    // service). But if a pre- or post-destroy event throws an exception, the
+    // cron update stage won't try to catch it. Instead, it will be caught and
+    // logged by the main cron service.
+    if ($event_class === PreDestroyEvent::class || $event_class === PostDestroyEvent::class) {
+      // If the pre-destroy event throws an exception or flags a validation
+      // error, the stage won't be destroyed. But, once the post-destroy event
+      // is fired, the stage should be fully destroyed and marked as available.
+      $this->assertSame($event_class === PostDestroyEvent::class, $stage->isAvailable());
+    }
+    else {
+      $this->assertTrue($stage->isAvailable());
+    }
+    $this->assertTrue($logged_by_stage);
+    $this->assertFalse($logged_by_cron);
+  }
+
+  /**
+   * Tests stage is destroyed if not available and site is on insecure version.
+   */
+  public function testStageDestroyedIfNotAvailable(): void {
+    $stage = $this->createStage();
+    $stage_id = $stage->create();
+    $original_stage_directory = $stage->getStageDirectory();
+    $this->assertDirectoryExists($original_stage_directory);
+
+    $listener = function (PostRequireEvent $event) use (&$cron_stage_dir, $original_stage_directory): void {
+      $this->assertDirectoryDoesNotExist($original_stage_directory);
+      $cron_stage_dir = $this->container->get('package_manager.stager')->getInvocationArguments()[0][1]->resolved();
+      $this->assertSame($event->stage->getStageDirectory(), $cron_stage_dir);
+      $this->assertDirectoryExists($cron_stage_dir);
+    };
+
+    $this->addEventTestListener($listener, PostRequireEvent::class);
+
+    $this->runConsoleUpdateStage();
+    $this->assertIsString($cron_stage_dir);
+    $this->assertNotEquals($original_stage_directory, $cron_stage_dir);
+    $this->assertDirectoryDoesNotExist($cron_stage_dir);
+    $this->assertTrue($this->logger->hasRecord('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.', (string) RfcLogLevel::NOTICE));
+    $stage2 = $this->createStage();
+    $stage2->create();
+
+    $this->expectException(StageOwnershipException::class);
+    $this->expectExceptionMessage('The existing stage was not in the process of being applied, so it was destroyed to allow updating the site to a secure version during cron.');
+    $stage->claim($stage_id);
+  }
+
+  /**
+   * Tests stage is not destroyed if another update is applying.
+   */
+  public function testStageNotDestroyedIfApplying(): void {
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
+    ]);
+    $this->setCoreVersion('9.8.0');
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['drupal/core:9.8.1']);
+    $stop_error = t('Stopping stage from applying');
+
+    // Add a PreApplyEvent event listener so we can attempt to run cron when
+    // another stage is applying.
+    $this->addEventTestListener(function (PreApplyEvent $event) use ($stop_error) {
+      // Ensure the stage that is applying the operation is not the cron
+      // update stage.
+      $this->assertInstanceOf(TestStage::class, $event->stage);
+      $this->runConsoleUpdateStage();
+      // We do not actually want to apply this operation it was just invoked to
+      // allow cron to be  attempted.
+      $event->addError([$stop_error]);
+    });
+
+    try {
+      $stage->apply();
+      $this->fail('Expected update to fail');
+    }
+    catch (StageEventException $exception) {
+      $this->assertExpectedResultsFromException([ValidationResult::createError([$stop_error])], $exception);
+    }
+
+    $this->assertTrue($this->logger->hasRecord("Cron will not perform any updates as an existing staged update is applying. The site is currently on an insecure version of Drupal core but will attempt to update to a secure version next time cron is run. This update may be applied manually at the <a href=\"%url\">update form</a>.", (string) RfcLogLevel::NOTICE));
+    $this->assertUpdateStagedTimes(1);
+  }
+
+  /**
+   * Tests stage is not destroyed if not available and site is on secure version.
+   */
+  public function testStageNotDestroyedIfSecure(): void {
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml",
+    ]);
+    $this->setCoreVersion('9.8.1');
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['drupal/random']);
+    $this->assertUpdateStagedTimes(1);
+
+    // Trigger CronUpdateStage, the above should cause it to detect a stage that
+    // is applying.
+    $this->runConsoleUpdateStage();
+
+    $this->assertTrue($this->logger->hasRecord('Cron will not perform any updates because there is an existing stage and the current version of the site is secure.', (string) RfcLogLevel::NOTICE));
+    $this->assertUpdateStagedTimes(1);
+  }
+
+  /**
+   * Tests that CronUpdateStage::begin() unconditionally throws an exception.
+   */
+  public function testBeginThrowsException(): void {
+    $this->expectExceptionMessage(DrushUpdateStage::class . '::begin() cannot be called directly.');
+    $this->container->get(DrushUpdateStage::class)
+      ->begin(['drupal' => '9.8.1']);
+  }
+
+  /**
+   * Tests that email is sent when an unattended update succeeds.
+   */
+  public function testEmailOnSuccess(): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    $this->runConsoleUpdateStage();
+
+    // Ensure we sent a success message to all recipients.
+    $expected_body = <<<END
+Congratulations!
+
+Drupal core was automatically updated from 9.8.0 to 9.8.1.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("Drupal core was successfully updated", $expected_body);
+  }
+
+  /**
+   * Data provider for ::testEmailOnFailure().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerEmailOnFailure(): array {
+    return [
+      'pre-create' => [
+        PreCreateEvent::class,
+      ],
+      'pre-require' => [
+        PreRequireEvent::class,
+      ],
+      'pre-apply' => [
+        PreApplyEvent::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests the failure e-mail when an unattended non-security update fails.
+   *
+   * @param string $event_class
+   *   The event class that should trigger the failure.
+   *
+   * @dataProvider providerEmailOnFailure
+   */
+  public function testNonUrgentFailureEmail(string $event_class): void {
+    // If the failure happens before the stage is even created, the stage
+    // fixture need not be manipulated.
+    if ($event_class !== PreCreateEvent::class) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.2');
+    }
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml',
+    ]);
+    $this->config('automatic_updates.settings')
+      ->set('unattended.level', CronUpdateStage::ALL)
+      ->save();
+
+    $error = ValidationResult::createError([
+      t('Error while updating!'),
+    ]);
+    $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get(DrushUpdateStage::class));
+    TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
+
+    $this->runConsoleUpdateStage();
+
+    $url = Url::fromRoute('update.report_update')
+      ->setAbsolute()
+      ->toString();
+
+    $expected_body = <<<END
+Drupal core failed to update automatically from 9.8.0 to 9.8.2. The following error was logged:
+
+{$exception->getMessage()}
+
+No immediate action is needed, but it is recommended that you visit $url to perform the update, or at least check that everything still looks good.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("Drupal core update failed", $expected_body);
+  }
+
+  /**
+   * Tests the failure e-mail when an unattended security update fails.
+   *
+   * @param string $event_class
+   *   The event class that should trigger the failure.
+   *
+   * @dataProvider providerEmailOnFailure
+   */
+  public function testSecurityUpdateFailureEmail(string $event_class): void {
+    // If the failure happens before the stage is even created, the stage
+    // fixture need not be manipulated.
+    if ($event_class !== PreCreateEvent::class) {
+      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    }
+
+    $error = ValidationResult::createError([
+      t('Error while updating!'),
+    ]);
+    TestSubscriber1::setTestResult([$error], $event_class);
+    $exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get(DrushUpdateStage::class));
+
+    $this->runConsoleUpdateStage();
+
+    $url = Url::fromRoute('update.report_update')
+      ->setAbsolute()
+      ->toString();
+
+    $expected_body = <<<END
+Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged:
+
+{$exception->getMessage()}
+
+Your site is running an insecure version of Drupal and should be updated as soon as possible. Visit $url to perform the update.
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent("URGENT: Drupal core update failed", $expected_body);
+  }
+
+  /**
+   * Tests the failure e-mail when an unattended update fails to apply.
+   */
+  public function testApplyFailureEmail(): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    $error = new \LogicException('I drink your milkshake!');
+    LoggingCommitter::setException($error);
+
+    $this->runConsoleUpdateStage();
+    $expected_body = <<<END
+Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following error was logged:
+
+Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup. Caused by LogicException, with this message: {$error->getMessage()}
+
+This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.
+
+If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.
+END;
+    $this->assertMessagesSent('URGENT: Drupal core update failed', $expected_body);
+  }
+
+  /**
+   * Tests that setLogger is called on the cron update stage service.
+   */
+  public function testLoggerIsSetByContainer(): void {
+    $stage_method_calls = $this->container->getDefinition('automatic_updates.cron_update_stage')->getMethodCalls();
+    $this->assertSame('setLogger', $stage_method_calls[0][0]);
+  }
+
+  /**
+   * Tests that maintenance mode is on when staged changes are applied.
+   */
+  public function testMaintenanceModeIsOnDuringApply(): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+
+    /** @var \Drupal\Core\State\StateInterface $state */
+    $state = $this->container->get('state');
+    // Before the update begins, we should have no indication that we have ever
+    // been in maintenance mode (i.e., the value in state is NULL).
+    $this->assertNull($state->get('system.maintenance_mode'));
+    $this->runConsoleUpdateStage();
+    $state->resetCache();
+    // @see \Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage::apply()
+    $this->assertTrue($this->logger->hasRecord('Unattended update was applied in maintenance mode.', RfcLogLevel::INFO));
+    // @see \Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage::postApply()
+    $this->assertTrue($this->logger->hasRecord('postApply() was called in maintenance mode.', RfcLogLevel::INFO));
+    // During post-apply, maintenance mode should have been explicitly turned
+    // off (i.e., set to FALSE).
+    $this->assertFalse($state->get('system.maintenance_mode'));
+  }
+
+  /**
+   * Data provider for ::testMaintenanceModeAffectedByException().
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerMaintenanceModeAffectedByException(): array {
+    return [
+      [InvalidArgumentException::class, FALSE],
+      [PreconditionException::class, FALSE],
+      [\Exception::class, TRUE],
+    ];
+  }
+
+  /**
+   * Tests that an exception during apply may keep the site in maintenance mode.
+   *
+   * @param string $exception_class
+   *   The class of the exception that should be thrown by the committer.
+   * @param bool $will_be_in_maintenance_mode
+   *   Whether or not the site will be in maintenance mode afterward.
+   *
+   * @dataProvider providerMaintenanceModeAffectedByException
+   */
+  public function testMaintenanceModeAffectedByException(string $exception_class, bool $will_be_in_maintenance_mode): void {
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+
+    $message = new TranslatableMessage('A fail whale upon your head!');
+    LoggingCommitter::setException(match ($exception_class) {
+      InvalidArgumentException::class =>
+      new InvalidArgumentException($message),
+      PreconditionException::class =>
+      new PreconditionException($this->createMock(PreconditionInterface::class), $message),
+      default =>
+      new $exception_class((string) $message),
+    });
+
+    /** @var \Drupal\Core\State\StateInterface $state */
+    $state = $this->container->get('state');
+    $this->assertNull($state->get('system.maintenance_mode'));
+    $this->runConsoleUpdateStage();
+    $this->assertFalse($this->logger->hasRecord('Unattended update was applied in maintenance mode.', RfcLogLevel::INFO));
+    $this->assertSame($will_be_in_maintenance_mode, $state->get('system.maintenance_mode'));
+  }
+
+  /**
+   * Tests that the cron lock is acquired and released during an update.
+   */
+  public function testCronIsLockedDuringUpdate(): void {
+    $lock_checked_on_events = [];
+    $lock = $this->container->get('lock');
+
+    // Add listeners to ensure the cron lock is acquired at the beginning of the
+    // update and only released in post-apply.
+    $lock_checker = function (StageEvent $event) use (&$lock_checked_on_events, $lock) {
+      // The lock should not be available, since it should have been acquired
+      // by the stage before pre-create, and released after post-apply.
+      $this->assertFalse($lock->lockMayBeAvailable('cron'));
+      $lock_checked_on_events[] = get_class($event);
+    };
+    $this->addEventTestListener($lock_checker, PreCreateEvent::class);
+    $this->addEventTestListener($lock_checker, PostApplyEvent::class);
+
+    $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
+    // Ensure that the cron lock is available before the update attempt.
+    $this->assertTrue($lock->lockMayBeAvailable('cron'));
+    $this->runConsoleUpdateStage();
+    // Ensure the lock was checked on pre-create and post-apply.
+    $this->assertSame([PreCreateEvent::class, PostApplyEvent::class], $lock_checked_on_events);
+    $this->assertTrue($lock->lockMayBeAvailable('cron'));
+
+    // Ensure that the cron lock is released when there is exception in the
+    // update.
+    $listener = function (): never {
+      throw new \Exception('Nope!');
+    };
+    $this->addEventTestListener($listener, PostCreateEvent::class);
+    $lock_checked_on_events = [];
+    $this->runConsoleUpdateStage();
+    $this->assertTrue($this->logger->hasRecordThatContains('Nope!', RfcLogLevel::ERROR));
+    $this->assertTrue($lock->lockMayBeAvailable('cron'));
+    $this->assertSame([PreCreateEvent::class], $lock_checked_on_events);
+  }
+
+  /**
+   * Asserts cron has not run.
+   *
+   * @see \common_test_cron_helper_cron()
+   */
+  private function assertNoCronRun(): void {
+    $this->assertNull($this->container->get('state')->get('common_test.cron'));
+  }
+
+}
diff --git a/tests/src/Kernel/HookCronTest.php b/tests/src/Kernel/HookCronTest.php
deleted file mode 100644
index a81fda6a72b05eac2b2aebea2439cd3b3236ad88..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/HookCronTest.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel;
-
-use Drupal\automatic_updates\CronUpdateStage;
-use Drupal\automatic_updates_test\Datetime\TestTime;
-use Drupal\package_manager\Event\StatusCheckEvent;
-
-/**
- * @group automatic_updates
- */
-class HookCronTest extends AutomaticUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates', 'automatic_updates_test'];
-
-  /**
-   * Tests that our cron hook will run status checks.
-   */
-  public function testStatusChecksRunOnCron(): void {
-    // Set the core version to 9.8.1 so there will not be an update attempted.
-    // The hook_cron implementations will not be run if there is an update.
-    // @see \Drupal\automatic_updates\CronUpdateStage::run()
-    // @todo Remove this is https://drupal.org/i/3357969
-    $this->setCoreVersion('9.8.1');
-    // Undo override of the 'serverApi' property from the parent test class.
-    // @see \Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase::setUp
-    $property = new \ReflectionProperty(CronUpdateStage::class, 'serverApi');
-    $property->setValue(NULL, 'cli');
-    $this->assertTrue(CronUpdateStage::isCommandLine());
-    $status_check_count = 0;
-    $this->addEventTestListener(function () use (&$status_check_count) {
-      $status_check_count++;
-    }, StatusCheckEvent::class);
-
-    // Since we're at the command line, status checks should still not run, even
-    // if we do run cron.
-    $this->container->get('cron')->run();
-    $this->assertSame(0, $status_check_count);
-
-    // If we are on the web the status checks should run.
-    $property->setValue(NULL, 'cgi-fcgi');
-    $this->assertFalse(CronUpdateStage::isCommandLine());
-    $this->container->get('cron')->run();
-    $this->assertSame(1, $status_check_count);
-
-    // Ensure that the status checks won't run if less than an hour has passed.
-    TestTime::setFakeTimeByOffset("+30 minutes");
-    $this->container->get('cron')->run();
-    $this->assertSame(1, $status_check_count);
-
-    // The status checks should run if more than an hour has passed.
-    TestTime::setFakeTimeByOffset("+61 minutes");
-    $this->container->get('cron')->run();
-    $this->assertSame(2, $status_check_count);
-  }
-
-}
diff --git a/tests/src/Kernel/ReleaseChooserTest.php b/tests/src/Kernel/ReleaseChooserTest.php
index c354980813158c07e1b4a895e67c5646e7a183ad..552dd77c31f0593e1b00915a6aafc0c2e37bd510 100644
--- a/tests/src/Kernel/ReleaseChooserTest.php
+++ b/tests/src/Kernel/ReleaseChooserTest.php
@@ -4,7 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\automatic_updates\Kernel;
 
-use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\automatic_updates\ReleaseChooser;
 use Drupal\automatic_updates\UpdateStage;
 use Drupal\Core\Extension\ExtensionVersion;
@@ -83,42 +83,42 @@ class ReleaseChooserTest extends AutomaticUpdatesKernelTestBase {
         'next_minor' => '9.8.2',
       ],
       'cron, installed 9.8.0, no minor support' => [
-        'stage' => CronUpdateStage::class,
+        'stage' => DrushUpdateStage::class,
         'minor_support' => FALSE,
         'installed_version' => '9.8.0',
         'current_minor' => '9.8.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.8.0, minor support' => [
-        'stage' => CronUpdateStage::class,
+        'stage' => DrushUpdateStage::class,
         'minor_support' => TRUE,
         'installed_version' => '9.8.0',
         'current_minor' => '9.8.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.0, no minor support' => [
-        'stage' => CronUpdateStage::class,
+        'stage' => DrushUpdateStage::class,
         'minor_support' => FALSE,
         'installed_version' => '9.7.0',
         'current_minor' => '9.7.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.0, minor support' => [
-        'stage' => CronUpdateStage::class,
+        'stage' => DrushUpdateStage::class,
         'minor_support' => TRUE,
         'installed_version' => '9.7.0',
         'current_minor' => '9.7.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.2, no minor support' => [
-        'stage' => CronUpdateStage::class,
+        'stage' => DrushUpdateStage::class,
         'minor_support' => FALSE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.2, minor support' => [
-        'stage' => CronUpdateStage::class,
+        'stage' => DrushUpdateStage::class,
         'minor_support' => TRUE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
diff --git a/tests/src/Kernel/StatusCheck/AutomatedCronDisabledValidatorTest.php b/tests/src/Kernel/StatusCheck/AutomatedCronDisabledValidatorTest.php
deleted file mode 100644
index ee89420d508970a524883590beb82dda616d2198..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/StatusCheck/AutomatedCronDisabledValidatorTest.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
-
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-
-/**
- * @covers \Drupal\automatic_updates\Validator\AutomatedCronDisabledValidator
- * @group automatic_updates
- * @internal
- */
-class AutomatedCronDisabledValidatorTest extends AutomaticUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates', 'automated_cron'];
-
-  /**
-   * Tests that cron updates are not allowed if Automated Cron is enabled.
-   */
-  public function testCronUpdateNotAllowed(): void {
-    $expected_results = [
-      ValidationResult::createWarning([
-        t('This site has the Automated Cron module installed. To use unattended automatic updates, configure cron manually on your hosting environment. The Automatic Updates module will not do anything if it is triggered by Automated Cron. See the <a href=":url">Automated Cron documentation</a> for information.', [
-          ':url' => 'https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview#s-more-reliable-enable-cron-using-external-trigger',
-        ]),
-      ]),
-    ];
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-
-    // Even after a cron run, we should have the same results.
-    $this->container->get('cron')->run();
-    $this->assertCheckerResultsFromManager($expected_results);
-  }
-
-}
diff --git a/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php b/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php
index 1fb92b83811efb1d7b21a1bf6594661cf00cb1ef..871eb07b16cc45315d70ad0e01eabc06678870a7 100644
--- a/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/CronFrequencyValidatorTest.php
@@ -7,6 +7,7 @@ namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
 use Drupal\automatic_updates\CronUpdateStage;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+use Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage;
 
 /**
  * @covers \Drupal\automatic_updates\Validator\CronFrequencyValidator
@@ -110,9 +111,21 @@ class CronFrequencyValidatorTest extends AutomaticUpdatesKernelTestBase {
     $this->container->get('state')->set('system.cron_last', $last_run);
     $this->assertCheckerResultsFromManager($expected_results, TRUE);
 
-    // After running cron, any errors or warnings should be gone.
-    $this->container->get('cron')->run();
-    $this->assertCheckerResultsFromManager([], TRUE);
+    try {
+      $this->container->get('cron')->run();
+      $this->fail('Expected an exception but one was not thrown.');
+    }
+    catch (\BadMethodCallException $e) {
+      // The terminal command cannot be run in a kernel test.
+      // @see \Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase::runUpdateViaConsole
+      $this->assertSame(TestCronUpdateStage::class, $e->getMessage());
+      // After cron runs, the validator should detect that cron ran recently.
+      // Even though the terminal command did not succeed, the decorated cron
+      // service from the System module should have been called before the
+      // attempt to run the terminal command.
+      // @see \Drupal\automatic_updates\CronUpdateStage::run
+      $this->assertCheckerResultsFromManager([], TRUE);
+    }
   }
 
 }
diff --git a/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php b/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php
deleted file mode 100644
index 9c7fa9026caec886f35a4866f55d3104365be812..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/StatusCheck/CronServerValidatorTest.php
+++ /dev/null
@@ -1,235 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
-
-use Drupal\automatic_updates\CronUpdateStage;
-use Drupal\automatic_updates\Validator\CronServerValidator;
-use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\Core\Url;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-use ColinODell\PsrTestLogger\TestLogger;
-use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
-
-/**
- * @covers \Drupal\automatic_updates\Validator\CronServerValidator
- * @group automatic_updates
- * @internal
- */
-class CronServerValidatorTest extends AutomaticUpdatesKernelTestBase {
-
-  use PackageManagerBypassTestTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['automatic_updates'];
-
-  /**
-   * Data provider for ::testCronServerValidation().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerCronServerValidation(): array {
-    $error = ValidationResult::createError([
-      t('Your site appears to be running on the built-in PHP web server on port 80. Drupal cannot be automatically updated with this configuration unless the site can also be reached on an alternate port.'),
-    ]);
-    // Add all the test cases where there no expected results for all cron
-    // modes.
-    foreach ([CronUpdateStage::DISABLED, CronUpdateStage::SECURITY, CronUpdateStage::ALL] as $cron_mode) {
-      $test_cases["PHP server with alternate port, cron $cron_mode"] = [
-        TRUE,
-        'cli-server',
-        $cron_mode,
-        [],
-      ];
-      $test_cases[" 'other server with alternate port, cron $cron_mode"] = [
-        TRUE,
-        'nginx',
-        $cron_mode,
-        [],
-      ];
-      $test_cases["other server with same port, cron $cron_mode"] = [
-        FALSE,
-        'nginx',
-        $cron_mode,
-        [],
-      ];
-    }
-    // If the PHP server is used with the same port and cron is enabled an error
-    // will be flagged.
-    foreach ([CronUpdateStage::SECURITY, CronUpdateStage::ALL] as $cron_mode) {
-      $test_cases["PHP server with same port, cron $cron_mode"] = [
-        FALSE,
-        'cli-server',
-        $cron_mode,
-        [$error],
-      ];
-    }
-    $test_cases["PHP server with same port, cron disabled"] = [
-      FALSE,
-      'cli-server',
-      CronUpdateStage::DISABLED,
-      [],
-    ];
-    return $test_cases;
-  }
-
-  /**
-   * Tests server validation during pre-create for unattended updates.
-   *
-   * @param bool $alternate_port
-   *   Whether or not an alternate port should be set.
-   * @param string $server_api
-   *   The value of the PHP_SAPI constant, as known to the validator.
-   * @param string $cron_mode
-   *   The cron mode to test with. Can be any of
-   *   \Drupal\automatic_updates\CronUpdateStage::DISABLED,
-   *   \Drupal\automatic_updates\CronUpdateStage::SECURITY, or
-   *   \Drupal\automatic_updates\CronUpdateStage::ALL.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerCronServerValidation
-   */
-  public function testCronServerValidationDuringPreCreate(bool $alternate_port, string $server_api, string $cron_mode, array $expected_results): void {
-    // If CronUpdateStage is disabled, a stage will never be created; nor will
-    // it if validation results happen before the stage is even created: in
-    // either case the stage fixture need not be manipulated.
-    if ($cron_mode !== CronUpdateStage::DISABLED && empty($expected_results)) {
-      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-    }
-    $request = $this->container->get('request_stack')->getCurrentRequest();
-    $this->assertNotEmpty($request);
-    $this->assertSame(80, $request->getPort());
-
-    $property = new \ReflectionProperty(CronServerValidator::class, 'serverApi');
-    $property->setAccessible(TRUE);
-    $property->setValue(NULL, $server_api);
-
-    $this->config('automatic_updates.settings')
-      ->set('unattended.level', $cron_mode)
-      ->set('cron_port', $alternate_port ? 2501 : 0)
-      ->save();
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-
-    $logger = new TestLogger();
-    $this->container->get('logger.factory')
-      ->get('automatic_updates')
-      ->addLogger($logger);
-
-    // If errors were expected, cron should not have run.
-    $this->container->get('cron')->run();
-    if ($expected_results) {
-      // Assert the update was not staged to ensure the error was flagged in
-      // PreCreateEvent and not PreApplyEvent.
-      $this->assertUpdateStagedTimes(0);
-      $error = $this->createStageEventExceptionFromResults($expected_results);
-      $this->assertTrue($logger->hasRecord($error->getMessage(), (string) RfcLogLevel::ERROR));
-    }
-    else {
-      $this->assertFalse($logger->hasRecords((string) RfcLogLevel::ERROR));
-    }
-  }
-
-  /**
-   * Tests server validation during pre-apply for unattended updates.
-   *
-   * @param bool $alternate_port
-   *   Whether or not an alternate port should be set.
-   * @param string $server_api
-   *   The value of the PHP_SAPI constant, as known to the validator.
-   * @param string $cron_mode
-   *   The cron mode to test with. Can be any of
-   *   \Drupal\automatic_updates\CronUpdateStage::DISABLED,
-   *   \Drupal\automatic_updates\CronUpdateStage::SECURITY, or
-   *   \Drupal\automatic_updates\CronUpdateStage::ALL.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerCronServerValidation
-   */
-  public function testCronServerValidationDuringPreApply(bool $alternate_port, string $server_api, string $cron_mode, array $expected_results): void {
-    // If CronUpdateStage is disabled, a stage will never be created, hence
-    // stage fixture need not be manipulated.
-    if ($cron_mode !== CronUpdateStage::DISABLED) {
-      $this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
-    }
-    $request = $this->container->get('request_stack')->getCurrentRequest();
-    $this->assertNotEmpty($request);
-    $this->assertSame(80, $request->getPort());
-
-    $logger = new TestLogger();
-    $this->container->get('logger.factory')
-      ->get('automatic_updates')
-      ->addLogger($logger);
-
-    $this->config('automatic_updates.settings')
-      ->set('unattended.level', $cron_mode)
-      ->save();
-
-    // Add a listener to change the $server_api and $alternate_port settings
-    // during PreApplyEvent. We set $cron_mode above because this determines
-    // whether updates will actually be run in cron.
-    $this->addEventTestListener(
-      function () use ($alternate_port, $server_api): void {
-        $property = new \ReflectionProperty(CronServerValidator::class, 'serverApi');
-        $property->setAccessible(TRUE);
-        $property->setValue(NULL, $server_api);
-        $this->config('automatic_updates.settings')
-          ->set('cron_port', $alternate_port ? 2501 : 0)
-          ->save();
-      }
-    );
-    // If errors were expected, cron should not have run.
-    $this->container->get('cron')->run();
-    if ($expected_results) {
-      $this->assertUpdateStagedTimes(1);
-      $error = $this->createStageEventExceptionFromResults($expected_results);
-      $this->assertTrue($logger->hasRecord($error->getMessage(), (string) RfcLogLevel::ERROR));
-    }
-    else {
-      $this->assertFalse($logger->hasRecords((string) RfcLogLevel::ERROR));
-    }
-  }
-
-  /**
-   * Tests server validation for unattended updates with Help enabled.
-   *
-   * @param bool $alternate_port
-   *   Whether or not an alternate port should be set.
-   * @param string $server_api
-   *   The value of the PHP_SAPI constant, as known to the validator.
-   * @param string $cron_mode
-   *   The cron mode to test with. Can contain be of
-   *   \Drupal\automatic_updates\CronUpdateStage::DISABLED,
-   *   \Drupal\automatic_updates\CronUpdateStage::SECURITY, or
-   *   \Drupal\automatic_updates\CronUpdateStage::ALL.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerCronServerValidation
-   */
-  public function testHelpLink(bool $alternate_port, string $server_api, string $cron_mode, array $expected_results): void {
-    $this->enableModules(['help']);
-
-    $url = Url::fromRoute('help.page')
-      ->setRouteParameter('name', 'automatic_updates')
-      ->setOption('fragment', 'cron-alternate-port')
-      ->toString();
-
-    foreach ($expected_results as $i => $result) {
-      $messages = [];
-      foreach ($result->messages as $message) {
-        $messages[] = t('@message See <a href=":url">the Automatic Updates help page</a> for more information on how to resolve this.', ['@message' => $message, ':url' => $url]);
-      }
-      $expected_results[$i] = ValidationResult::createError($messages);
-    }
-    $this->testCronServerValidationDuringPreApply($alternate_port, $server_api, $cron_mode, $expected_results);
-  }
-
-}
diff --git a/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php b/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php
index c8917b7a36db4ae693a2db6dcc21b5f534d5b595..0c1c4561872630b844fa083f99804990df39d882 100644
--- a/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/PhpExtensionsValidatorTest.php
@@ -61,7 +61,7 @@ class PhpExtensionsValidatorTest extends AutomaticUpdatesKernelTestBase {
       ->get('automatic_updates')
       ->addLogger($logger);
 
-    $this->container->get('cron')->run();
+    $this->runConsoleUpdateStage();
     // The update should have been stopped before it started.
     $this->assertUpdateStagedTimes(0);
     $this->assertTrue($logger->hasRecordThatContains((string) $error_result->messages[0], RfcLogLevel::ERROR));
@@ -85,7 +85,7 @@ class PhpExtensionsValidatorTest extends AutomaticUpdatesKernelTestBase {
       ->get('automatic_updates')
       ->addLogger($logger);
 
-    $this->container->get('cron')->run();
+    $this->runConsoleUpdateStage();
     // The update should have been staged, but then stopped with an error.
     $this->assertUpdateStagedTimes(1);
     $this->assertTrue($logger->hasRecordThatContains("Unattended updates are not allowed while Xdebug is enabled. You cannot receive updates, including security updates, until it is disabled.", RfcLogLevel::ERROR));
diff --git a/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php b/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php
index b435fb2badc9ecd02b7ad9d68bbebc6dc4bba00e..d4b3368541d60da8555bb6b6231355c5429fbdb8 100644
--- a/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/StagedDatabaseUpdateValidatorTest.php
@@ -38,7 +38,7 @@ class StagedDatabaseUpdateValidatorTest extends AutomaticUpdatesKernelTestBase {
     };
     $this->addEventTestListener($listener);
 
-    $this->container->get('cron')->run();
+    $this->runConsoleUpdateStage();
     $expected_message = "The update cannot proceed because database updates have been detected in the following extensions.\nSystem\n";
     $this->assertTrue($logger->hasRecord($expected_message, (string) RfcLogLevel::ERROR));
   }
diff --git a/tests/src/Kernel/StatusCheck/StatusCheckerTest.php b/tests/src/Kernel/StatusCheck/StatusCheckerTest.php
index ba2f79a055ba900c7d88aec5be3984b85e8c5fd1..7158aeaf9126c0781ddc02e1a3524d12a67c10ba 100644
--- a/tests/src/Kernel/StatusCheck/StatusCheckerTest.php
+++ b/tests/src/Kernel/StatusCheck/StatusCheckerTest.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\automatic_updates\UpdateStage;
 use Drupal\automatic_updates\Validation\StatusChecker;
 use Drupal\automatic_updates\Validator\StagedProjectsValidator;
@@ -209,7 +210,7 @@ class StatusCheckerTest extends AutomaticUpdatesKernelTestBase {
     $this->addEventTestListener($listener, StatusCheckEvent::class);
     $this->container->get(StatusChecker::class)->run();
     // By default, updates will be enabled on cron.
-    $this->assertInstanceOf(CronUpdateStage::class, $stage);
+    $this->assertInstanceOf(DrushUpdateStage::class, $stage);
     $this->config('automatic_updates.settings')
       ->set('unattended.level', CronUpdateStage::DISABLED)
       ->save();
diff --git a/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php b/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php
index a8e64641e8378c1b30bb376fc1e652acb9a822ae..5930296b9ad6704735881e6ec55782a9a8a4f074 100644
--- a/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/VersionPolicyValidatorTest.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
 
 use Drupal\automatic_updates\CronUpdateStage;
+use Drupal\automatic_updates\DrushUpdateStage;
 use Drupal\automatic_updates\UpdateStage;
 use Drupal\fixture_manipulator\ActiveFixtureManipulator;
 use Drupal\package_manager\Event\PreCreateEvent;
@@ -404,7 +405,7 @@ class VersionPolicyValidatorTest extends AutomaticUpdatesKernelTestBase {
     // that would get executed after pre-create.
     // @see \Drupal\automatic_updates\Validator\VersionPolicyValidator::validateVersion()
     $this->addEventTestListener(function (PreCreateEvent $event) use ($target_version): void {
-      /** @var \Drupal\Tests\automatic_updates\Kernel\TestCronUpdateStage $stage */
+      /** @var \Drupal\automatic_updates\DrushUpdateStage $stage */
       $stage = $event->stage;
       $stage->setMetadata('packages', [
         'production' => [
@@ -424,7 +425,7 @@ class VersionPolicyValidatorTest extends AutomaticUpdatesKernelTestBase {
         ->set('allow_core_minor_updates', $allow_minor_updates)
         ->save();
 
-      $stage = $this->container->get(CronUpdateStage::class);
+      $stage = $this->container->get(DrushUpdateStage::class);
       try {
         $stage->create();
         // If we did not get an exception, ensure we didn't expect any results.