diff --git a/classes/ActionScheduler_AdminView.php b/classes/ActionScheduler_AdminView.php
index cde70935..d407ff52 100644
--- a/classes/ActionScheduler_AdminView.php
+++ b/classes/ActionScheduler_AdminView.php
@@ -50,6 +50,10 @@ public static function instance() {
* @codeCoverageIgnore
*/
public function init() {
+ // Registered unconditionally: the failure is recorded from the queue runner, which usually
+ // runs outside the admin (cron/async), so the recorder must not sit behind the is_admin() guard.
+ add_action( 'action_scheduler_unrecognized_schedule_action', array( $this, 'note_unrecognized_schedule_failure' ) );
+
if ( is_admin() && ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) ) {
if ( class_exists( 'WooCommerce' ) ) {
@@ -60,6 +64,7 @@ public function init() {
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_notices', array( $this, 'maybe_check_pastdue_actions' ) );
+ add_action( 'admin_notices', array( $this, 'maybe_show_unrecognized_schedule_notice' ) );
add_action( 'current_screen', array( $this, 'add_help_tabs' ) );
}
}
@@ -241,6 +246,91 @@ protected function check_pastdue_actions() {
do_action( 'action_scheduler_pastdue_actions_extra_notices', $query_args );
}
+ /**
+ * Option flag set when one or more actions have been failed because their schedule referenced an
+ * unrecognized class. Drives the review notice below.
+ *
+ * @var string
+ */
+ const UNRECOGNIZED_SCHEDULE_NOTICE_OPTION = 'action_scheduler_unrecognized_schedule_failures';
+
+ /**
+ * Record that an action was failed because its schedule referenced an unrecognized class, so an
+ * admin notice can prompt an operator to review it.
+ *
+ * Fired from the queue runner, which usually runs outside the admin (cron/async), so this makes no
+ * assumptions about admin context. The flag is not autoloaded.
+ */
+ public function note_unrecognized_schedule_failure() {
+ if ( ! get_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION ) ) {
+ update_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION, 1, false );
+ }
+ }
+
+ /**
+ * Action: admin_notices
+ *
+ * Prompt operators to review actions failed because their schedule could not be recognized, and
+ * render until the operator dismisses the notice.
+ */
+ public function maybe_show_unrecognized_schedule_notice() {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ // Process an explicit dismissal.
+ if (
+ isset( $_GET['as_dismiss_unrecognized_schedule'], $_GET['_asnonce'] )
+ && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_asnonce'] ) ), 'as_dismiss_unrecognized_schedule' )
+ ) {
+ delete_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION );
+ return;
+ }
+
+ if ( ! get_option( self::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION ) ) {
+ return;
+ }
+
+ $failed_url = add_query_arg(
+ array(
+ 'page' => 'action-scheduler',
+ 'status' => ActionScheduler_Store::STATUS_FAILED,
+ 'order' => 'asc',
+ ),
+ admin_url( 'tools.php' )
+ );
+
+ $dismiss_url = wp_nonce_url(
+ add_query_arg( 'as_dismiss_unrecognized_schedule', '1' ),
+ 'as_dismiss_unrecognized_schedule',
+ '_asnonce'
+ );
+
+ // The whole sentence is one translatable string, so translators keep full context and natural
+ // word order. Only the HTML tags are pulled out as placeholders (opening/closing pairs) -- tags
+ // need no translation. esc_html__() on the sentence neutralizes any markup a rogue translation
+ // might contain (the %n$s tokens survive it), and each opening tag carries an escaped URL, so
+ // wp_kses() is unnecessary.
+ $message = sprintf(
+ /* translators: 1$s/2$s: opening/closing bold tags around "Action Scheduler:". 3$s/4$s: opening/closing tags of a link (around "failed") to the failed actions screen. 5$s/6$s: opening/closing tags of the dismiss link (around "Dismiss"). */
+ esc_html__( '%1$sAction Scheduler:%2$s one or more scheduled actions could not be run because their schedule references an unrecognized class, and have been marked %3$sfailed%4$s for your review. If an action is safe to run, use its Run link to force it. %5$sDismiss%6$s', 'action-scheduler' ),
+ '',
+ '',
+ '',
+ '',
+ '',
+ ''
+ );
+
+ wp_admin_notice(
+ $message,
+ array(
+ 'type' => 'warning',
+ 'paragraph_wrap' => true,
+ )
+ );
+ }
+
/**
* Provide more information about the screen and its data in the help tab.
*/
diff --git a/classes/ActionScheduler_ListTable.php b/classes/ActionScheduler_ListTable.php
index 911d8c11..7218a982 100644
--- a/classes/ActionScheduler_ListTable.php
+++ b/classes/ActionScheduler_ListTable.php
@@ -469,6 +469,10 @@ protected function get_schedule_display_string( ActionScheduler_Schedule $schedu
$schedule_display_string = '';
+ if ( is_a( $schedule, 'ActionScheduler_UnrecognizedSchedule' ) ) {
+ return __( 'Unrecognized schedule', 'action-scheduler' );
+ }
+
if ( is_a( $schedule, 'ActionScheduler_NullSchedule' ) ) {
return __( 'async', 'action-scheduler' );
}
@@ -570,7 +574,13 @@ protected function process_row_action( $action_id, $row_action_type ) {
try {
switch ( $row_action_type ) {
case 'run':
- $this->runner->process_action( $action_id, 'Admin List Table' );
+ // A failed action is not pending, so it cannot be run on the normal path. Force it, so
+ // an operator can retry a failed action or flush one whose schedule was unrecognized.
+ if ( ActionScheduler_Store::STATUS_FAILED === $this->store->get_status( $action_id ) ) {
+ $this->runner->force_run_action( $action_id, 'Admin List Table' );
+ } else {
+ $this->runner->process_action( $action_id, 'Admin List Table' );
+ }
break;
case 'cancel':
$this->store->cancel_action( $action_id );
diff --git a/classes/ActionScheduler_ScheduleDeserializer.php b/classes/ActionScheduler_ScheduleDeserializer.php
index ffd142b0..febe7b3b 100644
--- a/classes/ActionScheduler_ScheduleDeserializer.php
+++ b/classes/ActionScheduler_ScheduleDeserializer.php
@@ -305,6 +305,16 @@ protected function reject( $offending_class, $shadow_eligible ) {
do_action( 'action_scheduler_unexpected_schedule_class', $offending_class, $this->outer_class, $this->potential_offenders, $rejected );
if ( $rejected ) {
+ // A recoverable ($shadow_eligible) rejection is a structurally-valid schedule that merely
+ // references an unrecognized class — not evidently dangerous, just unknown. Return it as an
+ // ActionScheduler_UnrecognizedSchedule placeholder so the action stays visible and can be
+ // routed to a failed state for operator review / forced re-run, rather than being treated as
+ // irretrievably corrupt (false) and silently cancelled. Structural rejections (a top-level
+ // non-schedule or an unwalkable graph) have no legitimate form and remain corrupt.
+ if ( $shadow_eligible ) {
+ return new ActionScheduler_UnrecognizedSchedule( $this->potential_offenders );
+ }
+
return false;
}
diff --git a/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php b/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php
index 87fa61f9..4d177965 100644
--- a/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php
+++ b/classes/abstracts/ActionScheduler_Abstract_QueueRunner.php
@@ -58,9 +58,11 @@ public function __construct( ?ActionScheduler_Store $store = null, ?ActionSchedu
* @param int $action_id The action ID to process.
* @param string $context Optional identifier for the context in which this action is being processed, e.g. 'WP CLI' or 'WP Cron'
* Generally, this should be capitalised and not localised as it's a proper noun.
+ * @param bool $force Run the action even if it is not pending, and skip the unrecognized-schedule
+ * guard. Used by force_run_action() so an operator can flush stuck work.
* @throws \Exception When error running action.
*/
- public function process_action( $action_id, $context = '' ) {
+ public function process_action( $action_id, $context = '', $force = false ) {
// Temporarily override the error handler while we process the current action.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
set_error_handler(
@@ -89,7 +91,9 @@ function ( $type, $message ) {
try {
do_action( 'action_scheduler_before_execute', $action_id, $context );
- if ( ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
+ // A forced run (e.g. a site operator using the admin "Run" link) executes the action even
+ // if it is not pending, so it can flush work that is otherwise stuck.
+ if ( ! $force && ActionScheduler_Store::STATUS_PENDING !== $this->store->get_status( $action_id ) ) {
$valid_action = false;
do_action( 'action_scheduler_execution_ignored', $action_id, $context );
return;
@@ -104,6 +108,15 @@ function ( $type, $message ) {
return;
}
+ // An action whose schedule could not be recognized is not run automatically: we cannot
+ // know when it was meant to run, so we mark it failed for operator review rather than
+ // cancelling it. A forced run overrides this and executes the action's callback anyway.
+ if ( ! $force && $action->get_schedule() instanceof ActionScheduler_UnrecognizedSchedule ) {
+ $valid_action = false;
+ $this->fail_unrecognized_action( $action_id );
+ return;
+ }
+
$this->store->log_execution( $action_id );
$action->execute();
@@ -122,11 +135,45 @@ function ( $type, $message ) {
restore_error_handler();
}
- if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) {
+ // A forced run flushes a single occurrence on an operator's request; it must not advance a
+ // recurring series. The next instance was already scheduled when the action first ran, so
+ // rescheduling here would create a duplicate.
+ if ( ! $force && isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) {
$this->schedule_next_instance( $action, $action_id );
}
}
+ /**
+ * Force an action to run now, regardless of its current status.
+ *
+ * Intended for a site operator flushing stuck work from the admin list table — most usefully an
+ * action that was failed because its schedule references an unrecognized class, which cannot be run
+ * on the normal (automatic) path. A failed action would otherwise be fetched as a non-executable
+ * ActionScheduler_FinishedAction, so for the duration of this run we map it back to an executable
+ * action class. The action's callback runs; its (unreadable or valid) schedule is not consulted, so
+ * no next instance of a recurring series is scheduled.
+ *
+ * This executes an action regardless of status, so callers are responsible for authorizing the
+ * request (the admin list table gates it behind a per-row nonce and the manage_options capability).
+ *
+ * @param int $action_id Action ID.
+ * @param string $context Context in which the action is being run.
+ * @return void
+ */
+ public function force_run_action( $action_id, $context = '' ) {
+ $as_executable = static function ( $action_class, $status ) {
+ return ActionScheduler_Store::STATUS_FAILED === $status ? 'ActionScheduler_Action' : $action_class;
+ };
+
+ add_filter( 'action_scheduler_stored_action_class', $as_executable, 10, 2 );
+
+ try {
+ $this->process_action( $action_id, $context, true );
+ } finally {
+ remove_filter( 'action_scheduler_stored_action_class', $as_executable, 10 );
+ }
+ }
+
/**
* Fetches the action instance. On failure returns null instead of ActionScheduler_NullAction.
*
@@ -165,6 +212,34 @@ private function cancel_corrupted_action( int $action_id ) {
);
}
+ /**
+ * Marks an action failed because its schedule references a class Action Scheduler does not
+ * recognize. Unlike a corrupt action, this is recoverable: an operator can review it and force it
+ * to run, or make the referenced class available (via the
+ * action_scheduler_allowed_nested_schedule_classes filter) and re-run it normally.
+ *
+ * @param int $action_id Action ID.
+ * @return void
+ */
+ private function fail_unrecognized_action( int $action_id ) {
+ $this->store->mark_failure( $action_id );
+
+ /**
+ * Fires when an action is marked failed because its stored schedule references an unrecognized
+ * class, rather than being run or cancelled.
+ *
+ * @since 4.1.0
+ *
+ * @param int $action_id Action ID.
+ */
+ do_action( 'action_scheduler_unrecognized_schedule_action', $action_id );
+
+ ActionScheduler_Logger::instance()->log(
+ $action_id,
+ __( 'This action references a schedule that could not be recognized, so it has been marked as failed rather than run. Review it and, if it is safe, force it to run.', 'action-scheduler' )
+ );
+ }
+
/**
* Marks actions as either having failed execution or failed validation, as appropriate.
*
diff --git a/classes/schedules/ActionScheduler_UnrecognizedSchedule.php b/classes/schedules/ActionScheduler_UnrecognizedSchedule.php
new file mode 100644
index 00000000..28177f16
--- /dev/null
+++ b/classes/schedules/ActionScheduler_UnrecognizedSchedule.php
@@ -0,0 +1,46 @@
+unrecognized_classes = array_values( array_unique( $unrecognized_classes ) );
+ }
+
+ /**
+ * The unrecognized class names, for display and logging.
+ *
+ * @return string[]
+ */
+ public function get_unrecognized_classes() {
+ return $this->unrecognized_classes;
+ }
+}
diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist
index 721cbbf4..3830d4b5 100644
--- a/tests/phpunit.xml.dist
+++ b/tests/phpunit.xml.dist
@@ -18,6 +18,7 @@
./phpunit/jobstore/ActionScheduler_DBStoreMigrator_Test.php
./phpunit/jobstore/ActionScheduler_DBStore_Test.php
./phpunit/jobstore/ActionScheduler_ScheduleUnserialize_Test.php
+ ./phpunit/jobstore/ActionScheduler_UnrecognizedSchedule_Test.php
./phpunit/jobstore/ActionScheduler_HybridStore_Test.php
./phpunit/logging/ActionScheduler_DBLogger_Test.php
diff --git a/tests/phpunit/jobstore/ActionScheduler_ScheduleUnserialize_Test.php b/tests/phpunit/jobstore/ActionScheduler_ScheduleUnserialize_Test.php
index ecdbaeb1..dc7a7c07 100644
--- a/tests/phpunit/jobstore/ActionScheduler_ScheduleUnserialize_Test.php
+++ b/tests/phpunit/jobstore/ActionScheduler_ScheduleUnserialize_Test.php
@@ -370,10 +370,12 @@ function ( $offending, $outer, $unexpected, $rejected ) use ( &$captured ) {
public function test_nested_allow_list_filter_is_honored_per_call() {
$blob = serialize( new ActionScheduler_Test_Custom_Schedule_With_Property( time() + DAY_IN_SECONDS, new ActionScheduler_Test_Schedule_Helper( 42 ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
- // First call, no filter: the helper class is unexpected, so the blob is rejected.
- $this->assertFalse(
+ // First call, no filter: the helper class is unexpected, so the blob yields the unrecognized
+ // placeholder (a valid schedule referencing an unknown class is recoverable, not corrupt).
+ $this->assertInstanceOf(
+ 'ActionScheduler_UnrecognizedSchedule',
ActionScheduler_ScheduleDeserializer::unserialize( $blob ),
- 'An un-allow-listed nested support class should be rejected by default.'
+ 'An un-allow-listed nested support class should yield an unrecognized-schedule placeholder.'
);
// Second call, with the helper allow-listed via the filter: the blob is accepted. This also
@@ -438,8 +440,9 @@ public function test_deserializer_instance_is_reusable() {
array()
);
- // First: a blob nesting a gadget (rejected, populating internal offender/seen state).
- $this->assertFalse( $deserializer( $this->nested_gadget_blob() ) );
+ // First: a blob nesting a gadget (rejected to the unrecognized placeholder, populating internal
+ // offender/seen state).
+ $this->assertInstanceOf( 'ActionScheduler_UnrecognizedSchedule', $deserializer( $this->nested_gadget_blob() ) );
// Then: a clean third party schedule must still deserialize correctly.
$clean = serialize( new ActionScheduler_Test_Custom_Schedule( time() + HOUR_IN_SECONDS ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
@@ -458,7 +461,7 @@ public function test_constructor_nested_allow_list_is_used() {
$blob = serialize( new ActionScheduler_Test_Custom_Schedule_With_Property( time() + DAY_IN_SECONDS, new ActionScheduler_Test_Schedule_Helper( 7 ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
$without = new ActionScheduler_ScheduleDeserializer( array(), array() );
- $this->assertFalse( $without( $blob ), 'Nested support classes absent from the allow-list must be rejected.' );
+ $this->assertInstanceOf( 'ActionScheduler_UnrecognizedSchedule', $without( $blob ), 'Nested support classes absent from the allow-list yield an unrecognized-schedule placeholder.' );
$with = new ActionScheduler_ScheduleDeserializer(
array(),
@@ -548,7 +551,7 @@ public function test_gadget_nested_in_third_party_schedule_is_rejected_without_i
$result = ActionScheduler_ScheduleDeserializer::unserialize( $blob );
- $this->assertFalse( $result );
+ $this->assertInstanceOf( 'ActionScheduler_UnrecognizedSchedule', $result );
$this->assertFalse(
ActionScheduler_Test_Evil_Gadget::$fired,
'A gadget nested in a third party schedule was instantiated.'
@@ -586,4 +589,27 @@ public function test_tampered_blob_that_makes_a_trusted_class_throw_is_reported_
}
$this->assertNotNull( $fetched, 'Fetching a tampered action fataled instead of being handled.' );
}
+
+ /**
+ * A structurally-valid schedule that merely nests an unrecognized class is recoverable: it yields an
+ * ActionScheduler_UnrecognizedSchedule placeholder (carrying the offending class names) rather than
+ * being treated as irretrievably corrupt. A structural rejection (top-level non-schedule) stays
+ * corrupt (false).
+ */
+ public function test_recoverable_schedule_yields_unrecognized_placeholder_but_structural_stays_corrupt() {
+ // Valid third party schedule nesting an unknown helper class -> recoverable.
+ $recoverable = serialize( new ActionScheduler_Test_Custom_Schedule_With_Property( time() + DAY_IN_SECONDS, new ActionScheduler_Test_Schedule_Helper( 1 ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+ $schedule = ActionScheduler_ScheduleDeserializer::unserialize( $recoverable );
+
+ $this->assertInstanceOf( 'ActionScheduler_UnrecognizedSchedule', $schedule );
+ $this->assertContains(
+ 'ActionScheduler_Test_Schedule_Helper',
+ $schedule->get_unrecognized_classes(),
+ 'The placeholder should record the unrecognized class name for operator review.'
+ );
+
+ // A bare top-level non-schedule is a structural rejection, not recoverable -> stays corrupt.
+ $structural = serialize( new ActionScheduler_Test_Evil_Gadget() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
+ $this->assertFalse( ActionScheduler_ScheduleDeserializer::unserialize( $structural ) );
+ }
}
diff --git a/tests/phpunit/jobstore/ActionScheduler_UnrecognizedSchedule_Test.php b/tests/phpunit/jobstore/ActionScheduler_UnrecognizedSchedule_Test.php
new file mode 100644
index 00000000..c6020086
--- /dev/null
+++ b/tests/phpunit/jobstore/ActionScheduler_UnrecognizedSchedule_Test.php
@@ -0,0 +1,273 @@
+query( "DELETE FROM {$wpdb->actionscheduler_actions}" );
+ parent::setUp();
+ }
+
+ /**
+ * Persist a real, due, pending action, then overwrite its stored schedule with a blob that is a
+ * valid outer schedule nesting an unrecognized class — which the deserializer resolves to an
+ * ActionScheduler_UnrecognizedSchedule.
+ *
+ * @param string $hook Hook to schedule.
+ * @return int Action ID.
+ */
+ private function store_due_action_with_unrecognized_schedule( $hook ) {
+ global $wpdb;
+
+ $store = new ActionScheduler_DBStore();
+ $action = new ActionScheduler_Action( $hook, array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '1 hour ago' ) ) );
+ $action_id = $store->save_action( $action );
+
+ // A valid third party schedule (a placeholder in the safe parse, so nothing is instantiated)
+ // whose property holds a class Action Scheduler does not recognize.
+ $nul = chr( 0 );
+ $prop = $nul . '*' . $nul . 'timestamp';
+ $container = 'ActionScheduler_Test_Custom_Schedule';
+ $unknown = 'O:14:"Some_Unknown_X":0:{}';
+ $blob = 'O:' . strlen( $container ) . ':"' . $container . '":1:{s:' . strlen( $prop ) . ':"' . $prop . '";' . $unknown . '}';
+
+ $wpdb->update(
+ $wpdb->actionscheduler_actions,
+ array( 'schedule' => $blob ),
+ array( 'action_id' => $action_id ),
+ array( '%s' ),
+ array( '%d' )
+ );
+
+ return $action_id;
+ }
+
+ /**
+ * The deserializer resolves the tampered schedule to the placeholder, so the action fetches as a
+ * normal (displayable) action rather than a null action.
+ */
+ public function test_action_fetches_with_unrecognized_schedule_placeholder() {
+ $store = new ActionScheduler_DBStore();
+ $action_id = $this->store_due_action_with_unrecognized_schedule( 'as_test_unrecognized_fetch' );
+
+ $fetched = $store->fetch_action( $action_id );
+
+ $this->assertNotInstanceOf( 'ActionScheduler_NullAction', $fetched );
+ $this->assertInstanceOf( 'ActionScheduler_UnrecognizedSchedule', $fetched->get_schedule() );
+ }
+
+ /**
+ * Automatic processing must mark an unrecognized-schedule action failed (for review) rather than
+ * running it or cancelling it.
+ */
+ public function test_unrecognized_schedule_action_is_failed_not_run_or_cancelled() {
+ $hook = 'as_test_unrecognized_auto';
+ $ran = 0;
+ add_action(
+ $hook,
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $notified = false;
+ add_action(
+ 'action_scheduler_unrecognized_schedule_action',
+ function () use ( &$notified ) {
+ $notified = true;
+ }
+ );
+
+ $store = new ActionScheduler_DBStore();
+ $runner = ActionScheduler_Mocker::get_queue_runner( $store );
+ $action_id = $this->store_due_action_with_unrecognized_schedule( $hook );
+
+ $runner->process_action( $action_id );
+
+ $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $store->get_status( $action_id ), 'The action should be failed, not cancelled or run.' );
+ $this->assertSame( 0, $ran, 'An unrecognized-schedule action must not run automatically.' );
+ $this->assertTrue( $notified, 'The action_scheduler_unrecognized_schedule_action hook should fire.' );
+
+ remove_all_actions( $hook );
+ remove_all_actions( 'action_scheduler_unrecognized_schedule_action' );
+ }
+
+ /**
+ * A forced run (as a site operator would trigger from the admin list table) executes the action's
+ * callback despite the unreadable schedule, flushing the work, and completes the action.
+ */
+ public function test_forced_run_executes_unrecognized_schedule_action() {
+ $hook = 'as_test_unrecognized_forced';
+ $ran = 0;
+ add_action(
+ $hook,
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $store = new ActionScheduler_DBStore();
+ $runner = ActionScheduler_Mocker::get_queue_runner( $store );
+ $action_id = $this->store_due_action_with_unrecognized_schedule( $hook );
+
+ // First, automatic processing marks it failed.
+ $runner->process_action( $action_id );
+ $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $store->get_status( $action_id ) );
+
+ // Then a forced run executes the callback and completes it.
+ $runner->force_run_action( $action_id, 'Test' );
+
+ $this->assertSame( 1, $ran, 'A forced run must execute the action callback.' );
+ $this->assertSame( ActionScheduler_Store::STATUS_COMPLETE, $store->get_status( $action_id ) );
+
+ remove_all_actions( $hook );
+ }
+
+ /**
+ * The admin list table renders an unrecognized schedule with a clear label rather than falling
+ * through to the NullSchedule "async" display.
+ */
+ public function test_list_table_displays_unrecognized_schedule() {
+ $list_table = $this->make_list_table();
+ $method = new ReflectionMethod( $list_table, 'get_schedule_display_string' );
+ $method->setAccessible( true );
+
+ $this->assertSame( 'Unrecognized schedule', $method->invoke( $list_table, new ActionScheduler_UnrecognizedSchedule() ) );
+ }
+
+ /**
+ * The list table "Run" row action force-runs a failed action (previously impossible), so an
+ * operator can flush stuck work.
+ */
+ public function test_list_table_run_force_runs_a_failed_action() {
+ $hook = 'as_test_unrecognized_listtable';
+ $ran = 0;
+ add_action(
+ $hook,
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $store = new ActionScheduler_DBStore();
+ $runner = ActionScheduler_Mocker::get_queue_runner( $store );
+ $action_id = $this->store_due_action_with_unrecognized_schedule( $hook );
+
+ $runner->process_action( $action_id ); // Automatic pass marks it failed.
+ $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $store->get_status( $action_id ) );
+
+ $list_table = $this->make_list_table( $store, $runner );
+ $method = new ReflectionMethod( $list_table, 'process_row_action' );
+ $method->setAccessible( true );
+ $method->invoke( $list_table, $action_id, 'run' );
+
+ $this->assertSame( 1, $ran, 'The list table Run action should force-run a failed action.' );
+ $this->assertSame( ActionScheduler_Store::STATUS_COMPLETE, $store->get_status( $action_id ) );
+
+ remove_all_actions( $hook );
+ }
+
+ /**
+ * The notice flag is set when an unrecognized-schedule failure is noted, and cleared by a valid
+ * dismissal request.
+ */
+ public function test_notice_flag_is_set_and_dismissed() {
+ $admin = ActionScheduler_AdminView::instance();
+ $option = ActionScheduler_AdminView::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION;
+ delete_option( $option );
+
+ $admin->note_unrecognized_schedule_failure();
+ $this->assertNotEmpty( get_option( $option ), 'Noting a failure should set the notice flag.' );
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $_GET['as_dismiss_unrecognized_schedule'] = '1';
+ $_GET['_asnonce'] = wp_create_nonce( 'as_dismiss_unrecognized_schedule' );
+
+ ob_start();
+ $admin->maybe_show_unrecognized_schedule_notice();
+ ob_end_clean();
+
+ $this->assertFalse( (bool) get_option( $option ), 'A valid dismissal should clear the notice flag.' );
+
+ unset( $_GET['as_dismiss_unrecognized_schedule'], $_GET['_asnonce'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- test cleanup of simulated request state.
+ }
+
+ /**
+ * The notice renders for an administrator while the flag is set, and links to the failed actions
+ * screen.
+ */
+ public function test_notice_renders_for_admin_and_links_to_failed_actions() {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ update_option( ActionScheduler_AdminView::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION, 1, false );
+
+ ob_start();
+ ActionScheduler_AdminView::instance()->maybe_show_unrecognized_schedule_notice();
+ $html = ob_get_clean();
+
+ $this->assertStringContainsString( 'unrecognized class', $html );
+ $this->assertStringContainsString( 'status=failed', $html );
+
+ delete_option( ActionScheduler_AdminView::UNRECOGNIZED_SCHEDULE_NOTICE_OPTION );
+ }
+
+ /**
+ * Force-running a failed recurring action (with a still-valid schedule) must not schedule a
+ * duplicate next instance — the series already advanced when the action first ran.
+ */
+ public function test_forced_run_of_recurring_failed_action_does_not_reschedule() {
+ $hook = 'as_test_force_recurring';
+ add_action( $hook, '__return_true' );
+
+ $store = new ActionScheduler_DBStore();
+ $runner = ActionScheduler_Mocker::get_queue_runner( $store );
+
+ $schedule = new ActionScheduler_IntervalSchedule( as_get_datetime_object( '1 hour ago' ), HOUR_IN_SECONDS );
+ $action = new ActionScheduler_Action( $hook, array(), $schedule );
+ $action_id = $store->save_action( $action );
+ $store->mark_failure( $action_id );
+
+ $pending_query = array(
+ 'hook' => $hook,
+ 'status' => ActionScheduler_Store::STATUS_PENDING,
+ );
+ $this->assertSame( 0, (int) $store->query_actions( $pending_query, 'count' ) );
+
+ $runner->force_run_action( $action_id, 'Test' );
+
+ $this->assertSame(
+ 0,
+ (int) $store->query_actions( $pending_query, 'count' ),
+ 'A forced run of a failed recurring action must not schedule a duplicate next instance.'
+ );
+
+ remove_all_actions( $hook );
+ }
+
+ /**
+ * Build a list table instance for tests, loading the WP_List_Table base if the admin context that
+ * normally provides it has not been loaded.
+ *
+ * @param ActionScheduler_Store|null $store Store to use.
+ * @param ActionScheduler_QueueRunner|null $runner Runner to use.
+ * @return ActionScheduler_ListTable
+ */
+ private function make_list_table( $store = null, $runner = null ) {
+ if ( ! class_exists( 'WP_List_Table' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
+ }
+
+ $store = $store ? $store : new ActionScheduler_DBStore();
+ $runner = $runner ? $runner : ActionScheduler_Mocker::get_queue_runner( $store );
+
+ return new ActionScheduler_ListTable( $store, ActionScheduler_Logger::instance(), $runner );
+ }
+}