From f9c982d2e9e2886b6d99c1f4a2022c99d35b5163 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 8 Sep 2026 22:36:43 +0000 Subject: [PATCH] feat(routines): generation fencing, stagger offsets, and reconcile for the Action Scheduler bridge Closes #539. Generation fencing: bridge::register() mints a schedule generation, persists it in agents_routine_generation_, and stamps it into the scheduled action args as a trailing metadata element so the logical args (routine_id) stay stable. Fetched routine actions are wrapped in WP_Agent_Generation_Fenced_Action, which no-ops execution (and reports its schedule as canceled) once the stamped generation is no longer current; action_scheduler_stored_action cancels recurrence successors born with a stale generation. The wake listener resolves routines by logical args. Stagger: WP_Agent_Routine accepts stagger => bool|int and computes a deterministic id-derived first-run offset (crc32 seed, capped by MAX_STAGGER_SECONDS and the interval); the bridge applies it to the first run of interval routines. Cron expressions are never staggered. Reconcile: WP_Agent_Routine_Registry::reconcile() compares registry coverage against pending routine actions by logical identity, enqueues missing schedules, removes orphans (including actions left behind for durably paused routines), supports dry runs, and serializes runs behind an add_option() CAS lock with stale-takeover. Pause state is now durable (agents_routine_paused) so reconcile can tell intentional unscheduling from drift. Exposed as the agents/reconcile-routines ability (show_in_rest, manage_options, destructive + idempotent). Covered by tests/routines-durability-smoke.php against a fake in-memory Action Scheduler store. --- agents-api.php | 3 + composer.json | 1 + docs/channels-workflows-operations.md | 23 + ...lass-wp-agent-generation-fenced-action.php | 124 ++++ ...class-wp-agent-routine-action-identity.php | 84 +++ ...-agent-routine-action-scheduler-bridge.php | 380 ++++++++++- .../class-wp-agent-routine-registry.php | 194 +++++- src/Routines/class-wp-agent-routine.php | 78 ++- .../register-action-scheduler-listener.php | 6 + src/Routines/register-routine-abilities.php | 97 +++ src/Routines/register-routine-bridge-sync.php | 5 + stubs/action-scheduler-classes.php | 60 +- tests/routines-durability-smoke.php | 610 ++++++++++++++++++ 13 files changed, 1622 insertions(+), 43 deletions(-) create mode 100644 src/Routines/class-wp-agent-generation-fenced-action.php create mode 100644 src/Routines/class-wp-agent-routine-action-identity.php create mode 100644 src/Routines/register-routine-abilities.php create mode 100644 tests/routines-durability-smoke.php diff --git a/agents-api.php b/agents-api.php index 5d3accf2..ed4e2a97 100644 --- a/agents-api.php +++ b/agents-api.php @@ -333,9 +333,12 @@ require_once AGENTS_API_PATH . 'src/Workflows/register-workflow-bridge-sync.php'; require_once AGENTS_API_PATH . 'src/Workflows/register-action-scheduler-listener.php'; require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-routine.php'; +require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-routine-action-identity.php'; +require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-generation-fenced-action.php'; require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-routine-registry.php'; require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-routine-action-scheduler-bridge.php'; require_once AGENTS_API_PATH . 'src/Routines/register-routines.php'; +require_once AGENTS_API_PATH . 'src/Routines/register-routine-abilities.php'; require_once AGENTS_API_PATH . 'src/Routines/register-routine-bridge-sync.php'; require_once AGENTS_API_PATH . 'src/Routines/register-action-scheduler-listener.php'; require_once AGENTS_API_PATH . 'src/Triggers/class-wp-agent-event-trigger.php'; diff --git a/composer.json b/composer.json index 9cbda280..62833a8d 100644 --- a/composer.json +++ b/composer.json @@ -154,6 +154,7 @@ "php tests/workflow-lifecycle-smoke.php", "php tests/agents-workflow-ability-smoke.php", "php tests/routine-smoke.php", + "php tests/routines-durability-smoke.php", "php tests/event-trigger-smoke.php", "php tests/subagents-smoke.php", "php tests/access-decision-filter-smoke.php", diff --git a/docs/channels-workflows-operations.md b/docs/channels-workflows-operations.md index 5cfa669d..a3040519 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -323,6 +323,29 @@ Optional fields include `label`, `prompt`, `session_id`, and `meta`. When `sessi Action Scheduler bridges and listeners are optional operational adapters. The substrate detects Action Scheduler at runtime and no-ops cleanly when absent; `composer.json` suggests `woocommerce/action-scheduler` for scheduled workflow/routine execution. +### Routine generation fencing + +Every `WP_Agent_Routine_Action_Scheduler_Bridge::register()` mints a schedule generation (`wp_generate_uuid4()`), persists it in the non-autoloaded `agents_routine_generation_` option, and stamps it into the scheduled action's args as a trailing metadata element. The stamp is transparent: `WP_Agent_Routine_Action_Identity::logical_args()` strips it, so identity work (unschedule, coverage checks, the wake listener) always compares the logical args — `array( 'routine_id' => ... )` — never the stamped payload. Action Scheduler's own args matching is exact-equality, so nothing in the bridge ever queries by stamped args. + +Two hooks close the loop: + +- `action_scheduler_stored_action_instance` wraps every fetched routine action in `WP_Agent_Generation_Fenced_Action`. When the stamped generation no longer matches the persisted one, the fenced action's `execute()` no-ops (firing `agents_routine_action_fenced` for observability) and its `get_schedule()` reports a canceled schedule so the queue runner never repeats a superseded recurring chain. Unstamped legacy actions never match a live generation, so they drain as no-ops instead of double-firing beside their stamped replacements. +- `action_scheduler_stored_action` cancels a recurrence successor that was stored carrying a stale generation — the race where an in-flight old-chain action finishes after re-registration and AS's `repeat()` clones the superseded args. + +`WP_Agent_Routine_Registry::current_generation( $id )` exposes the persisted generation; `unregister()` deletes the tombstone. + +### Routine stagger + +Routines registered with the same interval would all fire in the same second. `WP_Agent_Routine` accepts `stagger => bool|int` (default `true` for interval routines, `false` for cron expressions, where the expression already *is* the slot; an int is an explicit max window in seconds). `WP_Agent_Routine::stagger_offset()` computes `crc32( 'agents_routine_stagger_' . $id ) % min( interval, max_window )`, capped by `WP_Agent_Routine::MAX_STAGGER_SECONDS` (one hour). The bridge adds the offset to the first-run timestamp. The offset depends only on the routine id, so re-registration always lands the routine back in the same slot. + +### Routine reconcile + +`WP_Agent_Routine_Registry::reconcile( array $opts = [] )` repairs drift between the registry and the Action Scheduler store. For every registered, non-paused routine it checks pending-action coverage by logical identity and enqueues a fresh schedule when coverage is missing; pending routine actions whose logical `routine_id` is not registered (or is durably paused) are unscheduled as orphans. It returns `array( 'enqueued' => [ids], 'removed' => [ids], 'unchanged' => [ids], 'errors' => [id => message] )`; `$opts['dry_run']` reports the same shape without writing. The run is serialized through an `add_option()` compare-and-set lock (`agents_routine_reconcile_lock`); a lock older than five minutes is treated as stale and taken over, and the lock is always released in a `finally`. + +Pause state is durable: the bridge maintains the `agents_routine_paused` option so reconcile can distinguish "unscheduled on purpose" from "missing by drift" across requests, even though the registry itself stays in-memory. + +The `agents/reconcile-routines` ability (`show_in_rest: true`, annotations `destructive: true, idempotent: true`) exposes the same operation to ability consumers: input `{ dry_run?: bool }`, output the reconcile report, permission `current_user_can( 'manage_options' )` filterable via `agents_reconcile_routines_permission`. + ## Transcripts and approvals Transcript contracts live in `src/Transcripts/` and runtime persister contracts live in `src/Runtime/`: diff --git a/src/Routines/class-wp-agent-generation-fenced-action.php b/src/Routines/class-wp-agent-generation-fenced-action.php new file mode 100644 index 00000000..b29599a6 --- /dev/null +++ b/src/Routines/class-wp-agent-generation-fenced-action.php @@ -0,0 +1,124 @@ + $args Stamped action args. + * @param \ActionScheduler_Schedule $schedule Original schedule. + * @param string $group Action group. + * @param string $routine_id Routine the action belongs to. + * @param string $expected_generation Generation stamped into the args ('' when unstamped). + */ + public function __construct( + string $hook, + array $args, + \ActionScheduler_Schedule $schedule, + string $group, + string $routine_id, + string $expected_generation + ) { + parent::__construct( $hook, $args, $schedule, $group ); + $this->routine_id = $routine_id; + $this->expected_generation = $expected_generation; + } + + /** + * Fire the scheduled hook only while the stamped generation is current. + * A fenced (stale) action no-ops and reports through + * `agents_routine_action_fenced` so consumers can observe the skip. + */ + public function execute(): void { + if ( $this->is_generation_current() ) { + parent::execute(); + return; + } + + /** + * Fires when a fetched routine action refused to execute because + * its stamped generation is no longer the routine's current + * generation. + * + * @param string $routine_id Routine id. + * @param string $expected_generation Stamped generation that lost the fence. + * @param WP_Agent_Generation_Fenced_Action $action The fenced action. + */ + do_action( 'agents_routine_action_fenced', $this->routine_id, $this->expected_generation, $this ); + } + + /** + * Report a canceled schedule when the generation is stale so the queue + * runner never repeats a superseded recurring action. + * + * @return \ActionScheduler_Schedule + */ + public function get_schedule(): \ActionScheduler_Schedule { + $schedule = parent::get_schedule(); + if ( $this->is_generation_current() ) { + return $schedule; + } + + $date = $schedule->get_date(); + return new \ActionScheduler_CanceledSchedule( + $date instanceof \DateTime ? $date : new \DateTime( 'now', new \DateTimeZone( 'UTC' ) ) + ); + } + + public function get_routine_id(): string { + return $this->routine_id; + } + + public function get_expected_generation(): string { + return $this->expected_generation; + } + + /** + * The fence is current only while a non-empty persisted generation + * exactly matches the generation stamped into the action args. When + * the option layer is absent (non-WordPress harness) there is nothing + * to fence against and execution proceeds. + */ + private function is_generation_current(): bool { + if ( ! function_exists( 'get_option' ) ) { + return true; + } + + $current = get_option( WP_Agent_Routine_Action_Scheduler_Bridge::generation_option_name( $this->routine_id ), '' ); + return '' !== $this->expected_generation + && is_string( $current ) + && '' !== $current + && hash_equals( $this->expected_generation, $current ); + } + } +} diff --git a/src/Routines/class-wp-agent-routine-action-identity.php b/src/Routines/class-wp-agent-routine-action-identity.php new file mode 100644 index 00000000..db3ded6b --- /dev/null +++ b/src/Routines/class-wp-agent-routine-action-identity.php @@ -0,0 +1,84 @@ + ... )`) stay stable for identity lookups. Action + * Scheduler matches args by exact JSON equality, so identity work across the + * substrate (unschedule, coverage checks, the wake listener) must compare + * logical args, never the stamped payload. + * + * @package AgentsAPI + */ + +namespace AgentsAPI\AI\Routines; + +defined( 'ABSPATH' ) || exit; + +final class WP_Agent_Routine_Action_Identity { + + private const GENERATION_KEY = '_agents_routine_generation'; + private const LOGICAL_COUNT_KEY = '_agents_routine_logical_arg_count'; + + /** + * Stamp a generation onto scheduled-action args as a trailing metadata + * element. The logical prefix is left untouched. + * + * @param array $args Logical args. + * @param string $generation Current schedule generation. + * @return array + */ + public static function with_generation( array $args, string $generation ): array { + $logical_count = count( $args ); + $args[] = array( + self::GENERATION_KEY => $generation, + self::LOGICAL_COUNT_KEY => $logical_count, + ); + + return $args; + } + + /** + * Read the stamped generation from scheduled-action args, when present. + * + * @param array $args Possibly-stamped args. + */ + public static function generation_from_args( array $args ): ?string { + if ( array() === $args ) { + return null; + } + + $marker = end( $args ); + if ( ! is_array( $marker ) ) { + return null; + } + + $generation = $marker[ self::GENERATION_KEY ] ?? null; + return is_string( $generation ) && '' !== $generation ? $generation : null; + } + + /** + * Strip the trailing generation metadata element, returning the logical + * args used for identity matching. + * + * @param array $args Possibly-stamped args. + * @return array + */ + public static function logical_args( array $args ): array { + if ( array() === $args ) { + return $args; + } + + $marker = end( $args ); + if ( ! is_array( $marker ) || null === self::generation_from_args( $args ) ) { + return $args; + } + + $count = isset( $marker[ self::LOGICAL_COUNT_KEY ] ) && is_numeric( $marker[ self::LOGICAL_COUNT_KEY ] ) + ? (int) $marker[ self::LOGICAL_COUNT_KEY ] + : count( $args ) - 1; + + return array_slice( $args, 0, max( 0, $count ) ); + } +} diff --git a/src/Routines/class-wp-agent-routine-action-scheduler-bridge.php b/src/Routines/class-wp-agent-routine-action-scheduler-bridge.php index 7a72560e..8f57b32d 100644 --- a/src/Routines/class-wp-agent-routine-action-scheduler-bridge.php +++ b/src/Routines/class-wp-agent-routine-action-scheduler-bridge.php @@ -5,10 +5,24 @@ * Mirrors {@see \AgentsAPI\AI\Workflows\WP_Agent_Workflow_Action_Scheduler_Bridge}: * agents-api does not require Action Scheduler. When AS is available we * register one recurring (or cron-expression) action per routine with a - * stable args array so the listener can resolve the routine on wake. + * stable logical args array so the listener can resolve the routine on wake. + * + * Durability behaviors layered on top: + * + * - Generation fencing: every register() mints a generation, persists it in + * `agents_routine_generation_`, and stamps it into the scheduled + * action's args. Fetched actions are wrapped in + * {@see WP_Agent_Generation_Fenced_Action}, which refuses to execute (and + * refuses to spawn a recurrence successor) once the stamped generation is + * no longer current. + * - Stagger: interval routines offset their first run by a deterministic, + * id-derived offset so co-scheduled routines do not all fire at the same + * second. + * - Paused state: pause()/resume() maintain a durable paused-id list so + * {@see WP_Agent_Routine_Registry::reconcile()} can tell "unscheduled + * on purpose" from "missing by drift". * * @package AgentsAPI - * @since 0.105.0 */ namespace AgentsAPI\AI\Routines; @@ -17,24 +31,61 @@ final class WP_Agent_Routine_Action_Scheduler_Bridge { - /** @since 0.105.0 */ public const SCHEDULED_HOOK = 'wp_agent_routine_run_scheduled'; - /** @since 0.105.0 */ public const GROUP = 'agents-api'; + private const GENERATION_OPTION_PREFIX = 'agents_routine_generation_'; + private const PAUSED_OPTION = 'agents_routine_paused'; + + private static bool $fence_registered = false; + public static function is_available(): bool { return function_exists( 'as_schedule_recurring_action' ) && function_exists( 'as_schedule_cron_action' ) && function_exists( 'as_unschedule_all_actions' ); } + /** + * Stable wp_option name holding the routine's current schedule generation. + */ + public static function generation_option_name( string $routine_id ): string { + return self::GENERATION_OPTION_PREFIX . $routine_id; + } + + /** + * The routine's current schedule generation, or null when none has been + * minted (or the option layer is absent). + */ + public static function current_generation( string $routine_id ): ?string { + if ( ! function_exists( 'get_option' ) ) { + return null; + } + + $value = get_option( self::generation_option_name( $routine_id ), '' ); + return is_string( $value ) && '' !== $value ? $value : null; + } + + /** + * Whether the routine was durably paused via {@see pause()}. + */ + public static function is_paused( string $routine_id ): bool { + if ( ! function_exists( 'get_option' ) ) { + return false; + } + + $paused = get_option( self::PAUSED_OPTION, array() ); + return is_array( $paused ) && in_array( $routine_id, $paused, true ); + } + /** * Register the routine's schedule with Action Scheduler. Existing * schedules for the same routine are unscheduled first to make this * idempotent — call freely on every plugin boot. * - * @since 0.105.0 + * Each call mints a fresh schedule generation BEFORE the old chain is + * unscheduled, so any previously-claimed instance of the old chain fences + * itself at execution time. * * @return bool True when a schedule was registered (or the * `wp_agent_routine_schedule_requested` hook was fired @@ -46,20 +97,34 @@ public static function register( WP_Agent_Routine $routine ): bool { * of whether Action Scheduler is loaded. Custom schedulers can * hook this to take over. * - * @since 0.105.0 - * * @param WP_Agent_Routine $routine */ do_action( 'wp_agent_routine_schedule_requested', $routine ); + // Registration implies the routine is active. + self::set_paused( $routine->get_id(), false ); + if ( ! self::is_available() ) { return false; } - $args = array( 'routine_id' => $routine->get_id() ); + $routine_id = $routine->get_id(); + $logical_args = array( 'routine_id' => $routine_id ); + + // Mint the new generation before mutating stored actions: an in-flight + // instance of the superseded chain fences itself against the new value. + $generation = null; + if ( self::has_option_layer() ) { + $generation = self::mint_generation(); + update_option( self::generation_option_name( $routine_id ), $generation, false ); + } // Unschedule prior occurrences for idempotency. - as_unschedule_all_actions( self::SCHEDULED_HOOK, $args, self::GROUP ); + self::unschedule_logical( $logical_args ); + + $args = null !== $generation + ? WP_Agent_Routine_Action_Identity::with_generation( $logical_args, $generation ) + : $logical_args; if ( WP_Agent_Routine::TRIGGER_EXPRESSION === $routine->get_trigger_type() ) { return ! empty( as_schedule_cron_action( @@ -72,7 +137,7 @@ public static function register( WP_Agent_Routine $routine ): bool { } return ! empty( as_schedule_recurring_action( - time(), + time() + $routine->stagger_offset(), $routine->get_interval_seconds(), self::SCHEDULED_HOOK, $args, @@ -81,31 +146,33 @@ public static function register( WP_Agent_Routine $routine ): bool { } /** - * Cancel every scheduled action this bridge owns for the given routine. - * - * @since 0.105.0 + * Cancel every scheduled action this bridge owns for the given routine, + * remove its generation tombstone, and clear any paused marker. */ public static function unregister( string $routine_id ): void { + if ( self::has_option_layer() ) { + delete_option( self::generation_option_name( $routine_id ) ); + } + self::set_paused( $routine_id, false ); + if ( ! self::is_available() ) { return; } - as_unschedule_all_actions( - self::SCHEDULED_HOOK, - array( 'routine_id' => $routine_id ), - self::GROUP - ); + self::unschedule_logical( array( 'routine_id' => $routine_id ) ); } /** * Cancel the recurring/cron schedule without removing the routine from - * the registry. Mirrors {@see unregister()}; the only behavioural - * difference is the upstream caller's intent (the routine stays in - * memory and can be {@see resume()}d). - * - * @since 0.106.0 + * the registry. The pause is recorded durably so + * {@see WP_Agent_Routine_Registry::reconcile()} does not re-enqueue a + * deliberately-paused routine. */ public static function pause( string $routine_id ): void { - self::unregister( $routine_id ); + self::set_paused( $routine_id, true ); + if ( ! self::is_available() ) { + return; + } + self::unschedule_logical( array( 'routine_id' => $routine_id ) ); } /** @@ -113,8 +180,6 @@ public static function pause( string $routine_id ): void { * routine. Idempotent — calling on a routine whose schedule is still * active simply re-registers (the underlying register call unschedules * first). - * - * @since 0.106.0 */ public static function resume( WP_Agent_Routine $routine ): bool { return self::register( $routine ); @@ -122,22 +187,273 @@ public static function resume( WP_Agent_Routine $routine ): bool { /** * Enqueue a single-shot action for the routine, in addition to its - * recurring schedule. The listener already resolves the routine by - * `routine_id`, so the same wake handler fires for both kinds of - * dispatch. - * - * @since 0.106.0 + * recurring schedule. The one-shot is stamped with the routine's current + * generation when one exists, so the fetched-action fence applies to it + * exactly like the recurring chain. */ public static function run_now( WP_Agent_Routine $routine ): bool { if ( ! self::is_available() || ! function_exists( 'as_enqueue_async_action' ) ) { return false; } + $logical_args = array( 'routine_id' => $routine->get_id() ); + $generation = self::current_generation( $routine->get_id() ); + $args = null !== $generation + ? WP_Agent_Routine_Action_Identity::with_generation( $logical_args, $generation ) + : $logical_args; + as_enqueue_async_action( self::SCHEDULED_HOOK, - array( 'routine_id' => $routine->get_id() ), + $args, self::GROUP ); return true; } + + /** + * All pending actions under the routine hook/group, hydrated from the + * store. Identity work (coverage, orphan scans) compares + * {@see WP_Agent_Routine_Action_Identity::logical_args()} of each action + * because Action Scheduler's own args matching is exact-equality and + * cannot see through the generation stamp. + * + * @return array Pending actions keyed by action id. + */ + public static function pending_routine_actions(): array { + if ( ! function_exists( 'as_get_scheduled_actions' ) || ! class_exists( '\ActionScheduler_Store' ) ) { + return array(); + } + + $ids = as_get_scheduled_actions( + array( + 'hook' => self::SCHEDULED_HOOK, + 'group' => self::GROUP, + 'status' => \ActionScheduler_Store::STATUS_PENDING, + 'per_page' => -1, + ), + 'ids' + ); + if ( ! is_array( $ids ) ) { + return array(); + } + + $actions = array(); + foreach ( $ids as $id ) { + $action_id = is_numeric( $id ) ? (int) $id : 0; + if ( $action_id <= 0 ) { + continue; + } + + try { + $actions[ $action_id ] = \ActionScheduler_Store::instance()->fetch_action( $action_id ); + } catch ( \Throwable $error ) { + unset( $error ); + continue; + } + } + + return $actions; + } + + /** + * Cancel one stored action by id. Returns true when the cancel call + * succeeded (or at least did not throw). + */ + public static function cancel_action_by_id( int $action_id ): bool { + if ( $action_id <= 0 || ! class_exists( '\ActionScheduler_Store' ) ) { + return false; + } + + try { + \ActionScheduler_Store::instance()->cancel_action( $action_id ); + } catch ( \Throwable $error ) { + unset( $error ); + return false; + } + + return true; + } + + /** + * Register the fetched-action fence and the stored-successor reconciler. + * Idempotent; the hooks only ever fire when Action Scheduler runs, and the + * callbacks additionally guard on AS classes being loaded. + */ + public static function register_generation_fence(): void { + if ( self::$fence_registered ) { + return; + } + self::$fence_registered = true; + + add_filter( 'action_scheduler_stored_action_instance', array( self::class, 'fence_stored_action' ), 10, 6 ); + add_action( 'action_scheduler_stored_action', array( self::class, 'reconcile_stored_successor' ), PHP_INT_MAX, 1 ); + } + + /** + * Fetched-action fence: wrap routine actions in a generation-fenced + * decorator whose execute() no-ops (and whose schedule reports canceled) + * once the stamped generation stops being current. + * + * Unstamped (pre-fencing) routine actions are wrapped with an empty + * expected generation, which never matches a live generation — they drain + * as no-ops instead of double-firing beside the stamped replacement chain + * that register() persists on every boot. + * + * @param mixed $action Instantiated action. + * @param string $hook Action hook. + * @param array $args Stored (possibly stamped) args. + * @param mixed $schedule Action schedule. + * @param string $group Action group. + * @param int $priority Action priority. + * @return mixed Original or fenced action. + */ + public static function fence_stored_action( $action, string $hook, array $args, $schedule, string $group, int $priority ) { + if ( ! $action instanceof \ActionScheduler_Action + || self::SCHEDULED_HOOK !== $hook + || self::GROUP !== $group + || ! $schedule instanceof \ActionScheduler_Schedule + ) { + return $action; + } + + $logical_args = WP_Agent_Routine_Action_Identity::logical_args( $args ); + $routine_id = isset( $logical_args['routine_id'] ) && is_string( $logical_args['routine_id'] ) + ? $logical_args['routine_id'] + : ''; + if ( '' === $routine_id || ! class_exists( __NAMESPACE__ . '\\WP_Agent_Generation_Fenced_Action' ) ) { + return $action; + } + + $expected = WP_Agent_Routine_Action_Identity::generation_from_args( $args ) ?? ''; + $fenced = new WP_Agent_Generation_Fenced_Action( $hook, $args, $schedule, $group, $routine_id, $expected ); + $fenced->set_priority( $priority ); + return $fenced; + } + + /** + * Cancel a recurrence successor that was stored carrying a stale + * generation. + * + * Action Scheduler clones successors from the executed action's args. + * Between a routine's re-registration (new generation, fresh chain) and an + * in-flight old-chain action finishing, AS's `repeat()` can store a + * successor stamped with the superseded generation. The register() call + * that advanced the generation already owns the live chain, so the clone + * is safe to cancel; without this the duplicate stale chain would linger + * fenced-but-present until the worker drained it. + * + * @param int|string $action_id Stored action id. + */ + public static function reconcile_stored_successor( $action_id ): void { + if ( ! is_numeric( $action_id ) || ! class_exists( '\ActionScheduler_Store' ) ) { + return; + } + $action_id = (int) $action_id; + + try { + $action = \ActionScheduler_Store::instance()->fetch_action( $action_id ); + } catch ( \Throwable $error ) { + unset( $error ); + return; + } + + if ( self::SCHEDULED_HOOK !== $action->get_hook() + || self::GROUP !== $action->get_group() + ) { + return; + } + + // NOTE: do not gate on the action's schedule here. The fetched action + // is already wrapped by the fence filter, whose get_schedule() reports + // a canceled (non-recurring) schedule for exactly the stale actions + // this reconciler exists to cancel — gating on is_recurring() would + // make the fence swallow the stale-successor cleanup. + $stamped = WP_Agent_Routine_Action_Identity::generation_from_args( $action->get_args() ); + if ( null === $stamped ) { + return; // Legacy unstamped chain; the boot-time re-register owns cleanup. + } + + $logical_args = WP_Agent_Routine_Action_Identity::logical_args( $action->get_args() ); + $routine_id = isset( $logical_args['routine_id'] ) && is_string( $logical_args['routine_id'] ) + ? $logical_args['routine_id'] + : ''; + if ( '' === $routine_id ) { + return; + } + + $current = self::current_generation( $routine_id ); + if ( null !== $current && hash_equals( $stamped, $current ) ) { + return; // Successor carries the current generation: the chain is live. + } + + self::cancel_action_by_id( $action_id ); + } + + /** + * Cancel every pending action whose logical args match, and fall back to + * Action Scheduler's exact-args bulk cancel when the granular store API is + * unavailable (plain-code harnesses). + * + * @param array $logical_args Logical routine args. + */ + private static function unschedule_logical( array $logical_args ): void { + if ( function_exists( 'as_get_scheduled_actions' ) && class_exists( '\ActionScheduler_Store' ) ) { + foreach ( self::pending_routine_actions() as $action_id => $action ) { + if ( WP_Agent_Routine_Action_Identity::logical_args( $action->get_args() ) === $logical_args ) { + self::cancel_action_by_id( (int) $action_id ); + } + } + return; + } + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( self::SCHEDULED_HOOK, $logical_args, self::GROUP ); + } + } + + private static function has_option_layer(): bool { + return function_exists( 'add_option' ) && function_exists( 'get_option' ) && function_exists( 'update_option' ) && function_exists( 'delete_option' ); + } + + private static function mint_generation(): string { + if ( function_exists( 'wp_generate_uuid4' ) ) { + return wp_generate_uuid4(); + } + + try { + return bin2hex( random_bytes( 16 ) ); + } catch ( \Throwable $error ) { + unset( $error ); + return uniqid( 'routine_gen_', true ); + } + } + + private static function set_paused( string $routine_id, bool $pause ): void { + if ( ! self::has_option_layer() ) { + return; + } + + $current = get_option( self::PAUSED_OPTION, array() ); + $current = is_array( $current ) ? $current : array(); + + $normalized = array(); + foreach ( $current as $id ) { + if ( is_string( $id ) && '' !== $id && ! in_array( $id, $normalized, true ) ) { + $normalized[] = $id; + } + } + + $has = in_array( $routine_id, $normalized, true ); + if ( $pause === $has ) { + return; + } + + if ( $pause ) { + $normalized[] = $routine_id; + } else { + $normalized = array_values( array_diff( $normalized, array( $routine_id ) ) ); + } + + update_option( self::PAUSED_OPTION, $normalized, false ); + } } diff --git a/src/Routines/class-wp-agent-routine-registry.php b/src/Routines/class-wp-agent-routine-registry.php index f4cf1ee9..a960c9ea 100644 --- a/src/Routines/class-wp-agent-routine-registry.php +++ b/src/Routines/class-wp-agent-routine-registry.php @@ -23,6 +23,17 @@ final class WP_Agent_Routine_Registry { + /** + * Option name of the reconcile compare-and-set lock. + */ + private const RECONCILE_LOCK_OPTION = 'agents_routine_reconcile_lock'; + + /** + * Seconds after which a held reconcile lock is treated as stale and may + * be taken over (a crashed holder must not strand future reconciles). + */ + private const RECONCILE_LOCK_TTL = 300; + /** * @var array */ @@ -86,11 +97,11 @@ public static function unregister( string $routine_id ) { * itself. The value object stays in the registry; the cron schedule is * cancelled. Use {@see resume()} to re-establish it later. * - * State (paused-vs-active) is intentionally NOT stored on the value - * object or the registry — both are stateless across requests. Consumers - * that want a "this routine is paused" UI persist that fact themselves - * (typically a `wp_options` flag) and re-fire `pause()` on each plugin - * boot. The substrate just provides the verb and the event. + * The bridge records paused ids durably (`agents_routine_paused` option) + * so {@see reconcile()} treats a paused routine as intentionally + * unscheduled rather than missing. The value object and registry stay + * stateless; consumers that need a "paused" UI read it through the + * bridge. * * @since 0.106.0 * @@ -190,6 +201,179 @@ public static function find( string $routine_id ): ?WP_Agent_Routine { return self::$routines[ $routine_id ] ?? null; } + /** + * The routine's current schedule generation as persisted by the Action + * Scheduler bridge, or null when none exists. + */ + public static function current_generation( string $routine_id ): ?string { + return WP_Agent_Routine_Action_Scheduler_Bridge::current_generation( $routine_id ); + } + + /** + * Reconcile the in-memory registry against the Action Scheduler store. + * + * Registry state and AS state drift: the AS table can be pruned, a site + * can be restored from backup, actions can be manually deleted. For every + * registered, non-paused routine this checks pending-action coverage by + * logical identity (hook + logical args + group) and enqueues a fresh + * schedule when coverage is missing; pending routine actions whose + * logical routine_id is not registered (or is durably paused) are + * unscheduled as orphans. + * + * The whole run is serialized through an add_option() compare-and-set + * lock (`agents_routine_reconcile_lock`); a lock older than five minutes + * is treated as stale and taken over. Dry runs report the same shape + * without writing anything (and without taking the lock). + * + * @param array $opts Recognised keys: `dry_run` (bool). + * @return array{enqueued:string[],removed:string[],unchanged:string[],errors:array} + */ + public static function reconcile( array $opts = array() ): array { + $dry_run = ! empty( $opts['dry_run'] ); + + if ( ! WP_Agent_Routine_Action_Scheduler_Bridge::is_available() ) { + return array( + 'enqueued' => array(), + 'removed' => array(), + 'unchanged' => array(), + 'errors' => array( '_scheduler' => 'Action Scheduler is not available.' ), + ); + } + + if ( $dry_run ) { + return self::reconcile_unlocked( true ); + } + + if ( ! self::acquire_reconcile_lock() ) { + return array( + 'enqueued' => array(), + 'removed' => array(), + 'unchanged' => array(), + 'errors' => array( '_lock' => 'Another routine reconcile is already running.' ), + ); + } + + try { + return self::reconcile_unlocked( false ); + } finally { + self::release_reconcile_lock(); + } + } + + /** + * @return array{enqueued:string[],removed:string[],unchanged:string[],errors:array} + */ + private static function reconcile_unlocked( bool $dry_run ): array { + $enqueued = array(); + $removed = array(); + $unchanged = array(); + $errors = array(); + + $pending = WP_Agent_Routine_Action_Scheduler_Bridge::pending_routine_actions(); + + $covered = array(); + foreach ( $pending as $action ) { + $routine_id = self::logical_routine_id( $action->get_args() ); + if ( '' !== $routine_id ) { + $covered[ $routine_id ] = true; + } + } + + foreach ( self::$routines as $routine_id => $routine ) { + if ( WP_Agent_Routine_Action_Scheduler_Bridge::is_paused( $routine_id ) ) { + continue; + } + + if ( isset( $covered[ $routine_id ] ) ) { + $unchanged[] = $routine_id; + continue; + } + + if ( $dry_run ) { + $enqueued[] = $routine_id; + continue; + } + + if ( WP_Agent_Routine_Action_Scheduler_Bridge::register( $routine ) ) { + $enqueued[] = $routine_id; + } else { + $errors[ $routine_id ] = 'Failed to enqueue the missing routine schedule.'; + } + } + + foreach ( $pending as $action_id => $action ) { + $routine_id = self::logical_routine_id( $action->get_args() ); + if ( '' === $routine_id ) { + continue; + } + + $orphan = ! isset( self::$routines[ $routine_id ] ) + || WP_Agent_Routine_Action_Scheduler_Bridge::is_paused( $routine_id ); + if ( ! $orphan ) { + continue; + } + + if ( $dry_run ) { + $removed[] = $routine_id; + continue; + } + + if ( WP_Agent_Routine_Action_Scheduler_Bridge::cancel_action_by_id( (int) $action_id ) ) { + $removed[] = $routine_id; + } else { + $errors[ $routine_id ] = 'Failed to remove the orphaned routine schedule.'; + } + } + + return array( + 'enqueued' => $enqueued, + 'removed' => array_values( array_unique( $removed ) ), + 'unchanged' => $unchanged, + 'errors' => $errors, + ); + } + + /** + * Resolve the logical routine id out of possibly-stamped action args. + * + * @param array $args Stored action args. + */ + private static function logical_routine_id( array $args ): string { + $logical = WP_Agent_Routine_Action_Identity::logical_args( $args ); + $value = $logical['routine_id'] ?? ( $logical[0] ?? '' ); + return is_string( $value ) ? $value : ''; + } + + /** + * Acquire the reconcile lock via an add_option() compare-and-set. A lock + * older than five minutes is stale and taken over. When there is no + * option layer (non-WordPress harness) callers are already + * single-process and the lock is a no-op success. + */ + private static function acquire_reconcile_lock(): bool { + if ( ! function_exists( 'add_option' ) || ! function_exists( 'get_option' ) || ! function_exists( 'delete_option' ) ) { + return true; + } + + if ( add_option( self::RECONCILE_LOCK_OPTION, time(), '', false ) ) { + return true; + } + + $existing = get_option( self::RECONCILE_LOCK_OPTION ); + if ( is_numeric( $existing ) && (int) $existing > time() - self::RECONCILE_LOCK_TTL ) { + return false; + } + + delete_option( self::RECONCILE_LOCK_OPTION ); + return add_option( self::RECONCILE_LOCK_OPTION, time(), '', false ); + } + + private static function release_reconcile_lock(): void { + if ( function_exists( 'delete_option' ) ) { + delete_option( self::RECONCILE_LOCK_OPTION ); + } + } + /** * @return WP_Agent_Routine[] */ diff --git a/src/Routines/class-wp-agent-routine.php b/src/Routines/class-wp-agent-routine.php index b819f7f4..db97b011 100644 --- a/src/Routines/class-wp-agent-routine.php +++ b/src/Routines/class-wp-agent-routine.php @@ -32,14 +32,22 @@ final class WP_Agent_Routine { public const TRIGGER_INTERVAL = 'interval'; public const TRIGGER_EXPRESSION = 'expression'; + /** + * Default upper bound for the deterministic first-run stagger window, in + * seconds. The effective window is always capped by the routine's own + * interval so a routine never waits longer than one interval to first run. + */ + public const MAX_STAGGER_SECONDS = 3600; + private string $id; private string $label; private string $agent_slug; private string $trigger_type; - private int $interval_s = 0; - private string $expression = ''; - private string $prompt = ''; - private string $session_id = ''; + private int $interval_s = 0; + private string $expression = ''; + private string $prompt = ''; + private string $session_id = ''; + private int $stagger_window = 0; /** @var array */ private array $meta = array(); @@ -50,7 +58,9 @@ final class WP_Agent_Routine { * `interval` (int seconds) OR * `expression` (cron string), * `prompt` (string), `session_id` - * (string), `meta` (array). + * (string), `stagger` (bool|int — see + * {@see stagger_offset()}), `meta` + * (array). */ public function __construct( string $id, array $args ) { $id = sanitize_title( $id ); @@ -85,6 +95,8 @@ public function __construct( string $id, array $args ) { $this->expression = trim( (string) $args['expression'] ); } + $this->stagger_window = self::resolve_stagger_window( $args['stagger'] ?? null, $this->trigger_type ); + $this->prompt = isset( $args['prompt'] ) && is_scalar( $args['prompt'] ) ? (string) $args['prompt'] : ''; $this->session_id = isset( $args['session_id'] ) && is_scalar( $args['session_id'] ) && '' !== (string) $args['session_id'] ? (string) $args['session_id'] @@ -119,6 +131,61 @@ public function get_interval_seconds(): int { return $this->interval_s; } + /** + * Deterministic first-run stagger offset in seconds. + * + * Routines registered with the same interval would otherwise all fire in + * the same second. The offset spreads them across a bounded window: + * `crc32( 'agents_routine_stagger_' . $id ) % min( interval, window )`. + * It depends only on the routine id, so re-registration always lands the + * routine back in the same slot. + * + * The `stagger` arg controls the window: `true` uses + * {@see MAX_STAGGER_SECONDS}, an int sets an explicit max window in + * seconds, and `false` (or `0`) disables staggering. Defaults to `true` + * for interval routines and `false` for cron-expression routines, where + * the expression already *is* the slot. + */ + public function stagger_offset(): int { + if ( self::TRIGGER_INTERVAL !== $this->trigger_type || $this->stagger_window <= 0 || $this->interval_s <= 0 ) { + return 0; + } + + $max_offset = min( $this->interval_s, $this->stagger_window ); + return abs( (int) crc32( 'agents_routine_stagger_' . $this->id ) ) % $max_offset; + } + + /** + * Configured stagger window ceiling in seconds (0 = staggering disabled). + */ + public function get_stagger_window(): int { + return self::TRIGGER_INTERVAL === $this->trigger_type ? $this->stagger_window : 0; + } + + /** + * @param mixed $stagger Raw `stagger` arg (bool|int|null). + * @param string $trigger_type Resolved trigger type. + */ + private static function resolve_stagger_window( $stagger, string $trigger_type ): int { + if ( self::TRIGGER_INTERVAL !== $trigger_type ) { + return 0; + } + + if ( null === $stagger ) { + $stagger = true; + } + + if ( is_bool( $stagger ) ) { + return $stagger ? self::MAX_STAGGER_SECONDS : 0; + } + + if ( is_numeric( $stagger ) ) { + return max( 0, (int) $stagger ); + } + + return 0; + } + public function get_expression(): string { return $this->expression; } @@ -156,6 +223,7 @@ public function to_array(): array { ); if ( self::TRIGGER_INTERVAL === $this->trigger_type ) { $out['interval'] = $this->interval_s; + $out['stagger'] = $this->stagger_window; } else { $out['expression'] = $this->expression; } diff --git a/src/Routines/register-action-scheduler-listener.php b/src/Routines/register-action-scheduler-listener.php index ef0462ca..cd5eab25 100644 --- a/src/Routines/register-action-scheduler-listener.php +++ b/src/Routines/register-action-scheduler-listener.php @@ -121,9 +121,15 @@ function dispatch_scheduled_routine_run( $args ): void { } /** + * Resolve the routine id out of scheduled-action args. Scheduled args carry a + * trailing generation-metadata element (see + * {@see WP_Agent_Routine_Action_Identity}), so identity is always resolved + * from the logical args, never the stamped payload. + * * @param array $args Scheduled action args. */ function self_extract_scheduled_routine_id( array $args ): string { + $args = WP_Agent_Routine_Action_Identity::logical_args( $args ); $value = $args['routine_id'] ?? ( $args[0] ?? '' ); return is_string( $value ) ? $value : ''; } diff --git a/src/Routines/register-routine-abilities.php b/src/Routines/register-routine-abilities.php new file mode 100644 index 00000000..395670bb --- /dev/null +++ b/src/Routines/register-routine-abilities.php @@ -0,0 +1,97 @@ + 'Reconcile Routines', + 'description' => 'Reconcile registered routines against the Action Scheduler store: enqueue missing routine schedules and remove orphaned ones. Supports a dry-run mode that only reports.', + 'category' => 'agents-api', + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'properties' => array( + 'dry_run' => array( + 'type' => 'boolean', + 'description' => 'Report what would change without enqueueing, removing, or writing any state.', + 'default' => false, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'enqueued' => array( + 'type' => 'array', + 'description' => 'Routine ids whose missing schedule was (or would be) enqueued.', + 'items' => array( 'type' => 'string' ), + ), + 'removed' => array( + 'type' => 'array', + 'description' => 'Routine ids whose orphaned scheduled action was (or would be) removed.', + 'items' => array( 'type' => 'string' ), + ), + 'unchanged' => array( + 'type' => 'array', + 'description' => 'Routine ids whose schedule was already covered.', + 'items' => array( 'type' => 'string' ), + ), + 'errors' => array( + 'type' => 'object', + 'description' => 'Failure messages keyed by routine id (or a `_`-prefixed key for run-level failures).', + ), + ), + ), + 'execute_callback' => __NAMESPACE__ . '\\agents_reconcile_routines', + 'permission_callback' => __NAMESPACE__ . '\\agents_reconcile_routines_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + ), + ) + ); + } +); + +/** + * Run the routine/schedule reconciliation. + * + * @param array $input Ability input. + * @return array + */ +function agents_reconcile_routines( array $input ) { + return WP_Agent_Routine_Registry::reconcile( + array( 'dry_run' => ! empty( $input['dry_run'] ) ) + ); +} + +/** + * Reconcile mutates scheduler state site-wide; gate on manage_options, + * filterable like the rest of the module surface. + * + * @param array $input Ability input. + */ +function agents_reconcile_routines_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false; + return (bool) apply_filters( 'agents_reconcile_routines_permission', $allowed, $input ); +} diff --git a/src/Routines/register-routine-bridge-sync.php b/src/Routines/register-routine-bridge-sync.php index 7103d85a..932a2426 100644 --- a/src/Routines/register-routine-bridge-sync.php +++ b/src/Routines/register-routine-bridge-sync.php @@ -61,3 +61,8 @@ static function ( WP_Agent_Routine $routine ): void { 10, 1 ); + +// The fence hooks listen to Action Scheduler's store lifecycle so fetched +// routine actions are generation-fenced and stale recurrence successors are +// cancelled. The callbacks no-op when Action Scheduler is absent. +WP_Agent_Routine_Action_Scheduler_Bridge::register_generation_fence(); diff --git a/stubs/action-scheduler-classes.php b/stubs/action-scheduler-classes.php index b36536b9..66db55d8 100644 --- a/stubs/action-scheduler-classes.php +++ b/stubs/action-scheduler-classes.php @@ -63,14 +63,72 @@ public function cancel_action( $action_id ): void {} } class ActionScheduler_Action { + /** + * @param string $hook Action hook. + * @param array $args Action args. + * @param ActionScheduler_Schedule|null $schedule Action schedule. + * @param string $group Action group. + */ + public function __construct( string $hook = '', array $args = array(), ?ActionScheduler_Schedule $schedule = null, string $group = '' ) {} + + public function execute() {} + public function get_hook(): string { return ''; } - /** @return array */ + /** @return array */ public function get_args(): array { return array(); } + + public function get_schedule(): ActionScheduler_Schedule { + return new ActionScheduler_NullSchedule(); + } + + public function get_group(): string { + return ''; + } + + /** @param int $priority Action priority. */ + public function set_priority( $priority ): void { + unset( $priority ); + } +} + +/** + * Action Scheduler schedule value object. + */ +abstract class ActionScheduler_Schedule { + public function is_recurring(): bool { + return false; + } + + public function get_date(): ?DateTime { + return null; + } + + /** + * @return DateTime|null + */ + public function get_next( DateTime $after ) { + unset( $after ); + return null; + } +} + +/** + * Non-recurring schedule. + */ +class ActionScheduler_NullSchedule extends ActionScheduler_Schedule {} + +/** + * Canceled (never-recurring) schedule used to fence superseded recurrences. + */ +class ActionScheduler_CanceledSchedule extends ActionScheduler_Schedule { + public function __construct( DateTime $date ) { + unset( $date ); + } } /** diff --git a/tests/routines-durability-smoke.php b/tests/routines-durability-smoke.php new file mode 100644 index 00000000..0cb7bff7 --- /dev/null +++ b/tests/routines-durability-smoke.php @@ -0,0 +1,610 @@ +code; + } + public function get_error_message(): string { + return $this->message; + } + public function get_error_data() { + return $this->data; + } + } +} +if ( ! function_exists( 'wp_generate_uuid4' ) ) { + function wp_generate_uuid4(): string { + static $seq = 0; + ++$seq; + return sprintf( 'gen-%08d-%s', $seq, bin2hex( random_bytes( 4 ) ) ); + } +} + +// Mini hook system (mirrors tests/agents-api-smoke-helpers.php). +$GLOBALS['smoke_hooks'] = array(); +if ( ! function_exists( 'add_action' ) ) { + function add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { + unset( $accepted_args ); + $GLOBALS['smoke_hooks'][ $hook ][ $priority ][] = $callback; + } +} +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): void { + add_action( $hook, $callback, $priority, $accepted_args ); + } +} +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( string $hook, callable $callback, int $priority = 10 ): void { + foreach ( $GLOBALS['smoke_hooks'][ $hook ] ?? array() as $prio => $callbacks ) { + foreach ( $callbacks as $index => $existing ) { + if ( $existing === $callback ) { + unset( $GLOBALS['smoke_hooks'][ $hook ][ $prio ][ $index ] ); + } + } + } + } +} +if ( ! function_exists( 'do_action' ) ) { + function do_action( string $hook, ...$args ): void { + $callbacks = $GLOBALS['smoke_hooks'][ $hook ] ?? array(); + ksort( $callbacks ); + foreach ( $callbacks as $priority_callbacks ) { + foreach ( $priority_callbacks as $callback ) { + call_user_func_array( $callback, $args ); + } + } + $GLOBALS['smoke_fired_hooks'][] = array( $hook, $args ); + } +} +if ( ! function_exists( 'do_action_ref_array' ) ) { + function do_action_ref_array( string $hook, array $args ): void { + do_action( $hook, ...$args ); + } +} +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$args ) { + $callbacks = $GLOBALS['smoke_hooks'][ $hook ] ?? array(); + ksort( $callbacks ); + foreach ( $callbacks as $priority_callbacks ) { + foreach ( $priority_callbacks as $callback ) { + $value = call_user_func_array( $callback, array_merge( array( $value ), $args ) ); + } + } + return $value; + } +} + +// Option layer backed by a plain array. +$GLOBALS['smoke_options'] = array(); +if ( ! function_exists( 'add_option' ) ) { + function add_option( string $name, $value = '', string $deprecated = '', $autoload = null ): bool { + unset( $deprecated, $autoload ); + if ( array_key_exists( $name, $GLOBALS['smoke_options'] ) ) { + return false; + } + $GLOBALS['smoke_options'][ $name ] = $value; + return true; + } +} +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $name, $default = false ) { + return $GLOBALS['smoke_options'][ $name ] ?? $default; + } +} +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $name, $value, $autoload = null ): bool { + unset( $autoload ); + $GLOBALS['smoke_options'][ $name ] = $value; + return true; + } +} +if ( ! function_exists( 'delete_option' ) ) { + function delete_option( string $name ): bool { + unset( $GLOBALS['smoke_options'][ $name ] ); + return true; + } +} + +// Fake chat ability invoked by the scheduled-run listener. +class Smoke_Chat_Ability { + /** @var array> */ + public array $calls = array(); + + /** + * @param array $input Chat input. + * @return array + */ + public function execute( array $input ): array { + $this->calls[] = $input; + return array( 'ok' => true ); + } +} +$GLOBALS['smoke_chat_ability'] = new Smoke_Chat_Ability(); +if ( ! function_exists( 'wp_get_ability' ) ) { + function wp_get_ability( string $name ): ?Smoke_Chat_Ability { + return 'agents/chat' === $name ? $GLOBALS['smoke_chat_ability'] : null; + } +} + +// --------------------------------------------------------------------------- +// Fake Action Scheduler. +// --------------------------------------------------------------------------- + +$GLOBALS['smoke_as'] = array(); +$GLOBALS['smoke_as_next_id'] = 0; + +class ActionScheduler_Schedule { + public function __construct( private ?int $timestamp = null, private int $interval = 0, private string $cron = '' ) {} + + public function is_recurring(): bool { + return $this->interval > 0 || '' !== $this->cron; + } + + public function get_date(): ?DateTime { + return null === $this->timestamp ? null : new DateTime( '@' . $this->timestamp ); + } + + /** @return int|string */ + public function get_recurrence() { + return '' !== $this->cron ? $this->cron : $this->interval; + } +} + +class ActionScheduler_CanceledSchedule extends ActionScheduler_Schedule { + public function __construct( DateTime $date ) { + parent::__construct( $date->getTimestamp(), 0, '' ); + } + + public function is_recurring(): bool { + return false; + } +} + +class ActionScheduler_Action { + public function __construct( private string $hook = '', private array $args = array(), private ?ActionScheduler_Schedule $schedule = null, private string $group = '' ) { + $this->schedule = $schedule ?? new ActionScheduler_Schedule(); + } + + public function execute(): void { + do_action_ref_array( $this->hook, array_values( $this->args ) ); + } + + public function get_hook(): string { + return $this->hook; + } + + /** @return array */ + public function get_args(): array { + return $this->args; + } + + public function get_schedule(): ActionScheduler_Schedule { + return $this->schedule; + } + + public function get_group(): string { + return $this->group; + } + + /** @param int $priority Action priority. */ + public function set_priority( $priority ): void { + unset( $priority ); + } +} + +class ActionScheduler_Store { + public const STATUS_PENDING = 'pending'; + public const STATUS_RUNNING = 'in-progress'; + public const STATUS_CANCELED = 'canceled'; + + public static function instance(): self { + static $instance = null; + if ( null === $instance ) { + $instance = new self(); + } + return $instance; + } + + /** @param int|string $action_id Action id. */ + public function fetch_action( $action_id ): ActionScheduler_Action { + $row = $GLOBALS['smoke_as'][ (int) $action_id ] ?? null; + if ( null === $row ) { + throw new RuntimeException( 'unknown action' ); + } + + $schedule = new ActionScheduler_Schedule( $row['timestamp'], $row['interval'], $row['cron'] ); + $action = new ActionScheduler_Action( $row['hook'], $row['args'], $schedule, $row['group'] ); + return apply_filters( 'action_scheduler_stored_action_instance', $action, $row['hook'], $row['args'], $schedule, $row['group'], 10 ); + } + + /** @param int|string $action_id Action id. */ + public function cancel_action( $action_id ): void { + if ( isset( $GLOBALS['smoke_as'][ (int) $action_id ] ) ) { + $GLOBALS['smoke_as'][ (int) $action_id ]['status'] = self::STATUS_CANCELED; + } + } +} + +/** + * @param array $args + */ +function smoke_as_save( string $hook, array $args, string $group, int $timestamp, int $interval = 0, string $cron = '' ): int { + $id = ++$GLOBALS['smoke_as_next_id']; + $GLOBALS['smoke_as'][ $id ] = array( + 'hook' => $hook, + 'args' => $args, + 'group' => $group, + 'timestamp' => $timestamp, + 'interval' => $interval, + 'cron' => $cron, + 'status' => ActionScheduler_Store::STATUS_PENDING, + ); + do_action( 'action_scheduler_stored_action', $id ); + return $id; +} + +/** @param array $args */ +function as_schedule_recurring_action( int $timestamp, int $interval, string $hook, array $args = array(), string $group = '' ): int { + return smoke_as_save( $hook, $args, $group, $timestamp, $interval ); +} + +/** @param array $args */ +function as_schedule_cron_action( int $timestamp, string $schedule, string $hook, array $args = array(), string $group = '' ): int { + return smoke_as_save( $hook, $args, $group, $timestamp, 0, $schedule ); +} + +/** @param array $args */ +function as_enqueue_async_action( string $hook, array $args = array(), string $group = '' ): int { + return smoke_as_save( $hook, $args, $group, time() ); +} + +/** @param array $args */ +function as_unschedule_all_actions( string $hook, array $args = array(), string $group = '' ): void { + foreach ( $GLOBALS['smoke_as'] as $id => $row ) { + if ( $row['hook'] === $hook && $row['group'] === $group && $row['args'] === $args && ActionScheduler_Store::STATUS_PENDING === $row['status'] ) { + $GLOBALS['smoke_as'][ $id ]['status'] = ActionScheduler_Store::STATUS_CANCELED; + } + } +} + +/** + * @param array $query + * @return array + */ +function as_get_scheduled_actions( array $query = array(), string $return_format = 'OBJECT' ): array { + $ids = array(); + foreach ( $GLOBALS['smoke_as'] as $id => $row ) { + if ( isset( $query['hook'] ) && $row['hook'] !== $query['hook'] ) { + continue; + } + if ( isset( $query['group'] ) && $row['group'] !== $query['group'] ) { + continue; + } + if ( isset( $query['status'] ) && $row['status'] !== $query['status'] ) { + continue; + } + $ids[] = $id; + } + + if ( 'ids' === $return_format ) { + return $ids; + } + + $actions = array(); + foreach ( $ids as $id ) { + $actions[ $id ] = ActionScheduler_Store::instance()->fetch_action( $id ); + } + return $actions; +} + +// --------------------------------------------------------------------------- +// Module under test. +// --------------------------------------------------------------------------- + +require_once __DIR__ . '/../src/Routines/class-wp-agent-routine.php'; +require_once __DIR__ . '/../src/Routines/class-wp-agent-routine-action-identity.php'; +require_once __DIR__ . '/../src/Routines/class-wp-agent-generation-fenced-action.php'; +require_once __DIR__ . '/../src/Routines/class-wp-agent-routine-registry.php'; +require_once __DIR__ . '/../src/Routines/class-wp-agent-routine-action-scheduler-bridge.php'; +require_once __DIR__ . '/../src/Routines/register-routine-bridge-sync.php'; +require_once __DIR__ . '/../src/Routines/register-action-scheduler-listener.php'; + +use AgentsAPI\AI\Routines\WP_Agent_Generation_Fenced_Action; +use AgentsAPI\AI\Routines\WP_Agent_Routine; +use AgentsAPI\AI\Routines\WP_Agent_Routine_Action_Identity; +use AgentsAPI\AI\Routines\WP_Agent_Routine_Action_Scheduler_Bridge; +use AgentsAPI\AI\Routines\WP_Agent_Routine_Registry; + +function smoke_reset_state(): void { + WP_Agent_Routine_Registry::reset(); + $GLOBALS['smoke_as'] = array(); + $GLOBALS['smoke_as_next_id'] = 0; + $GLOBALS['smoke_options'] = array(); + $GLOBALS['smoke_fired_hooks'] = array(); + $GLOBALS['smoke_chat_ability'] = new Smoke_Chat_Ability(); +} + +/** @return array> */ +function smoke_pending_rows(): array { + $rows = array(); + foreach ( $GLOBALS['smoke_as'] as $id => $row ) { + if ( ActionScheduler_Store::STATUS_PENDING === $row['status'] ) { + $rows[ $id ] = $row; + } + } + return $rows; +} + +function smoke_hook_fired( string $hook ): bool { + foreach ( $GLOBALS['smoke_fired_hooks'] as $entry ) { + if ( $entry[0] === $hook ) { + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// 1. Action identity: stamp, strip, read. +// --------------------------------------------------------------------------- + +$logical = array( 'routine_id' => 'alpha' ); +$stamped = WP_Agent_Routine_Action_Identity::with_generation( $logical, 'gen-1' ); +durable_assert( array( 'routine_id' => 'alpha' ), WP_Agent_Routine_Action_Identity::logical_args( $stamped ), 'identity: logical_args strips the generation marker' ); +durable_assert( 'gen-1', WP_Agent_Routine_Action_Identity::generation_from_args( $stamped ), 'identity: generation_from_args reads the stamp' ); +durable_assert( null, WP_Agent_Routine_Action_Identity::generation_from_args( $logical ), 'identity: unstamped args have no generation' ); +durable_assert( $logical, WP_Agent_Routine_Action_Identity::logical_args( $logical ), 'identity: unstamped args pass through unchanged' ); +durable_assert( 2, count( $stamped ), 'identity: stamp appends exactly one element' ); + +// --------------------------------------------------------------------------- +// 2. Stagger offsets on the value object. +// --------------------------------------------------------------------------- + +$stag_alpha = new WP_Agent_Routine( 'stag-alpha', array( 'agent' => 'commander', 'interval' => 3600 ) ); +$stag_beta = new WP_Agent_Routine( 'stag-beta', array( 'agent' => 'commander', 'interval' => 3600 ) ); +durable_assert( 1517, $stag_alpha->stagger_offset(), 'stagger: stag-alpha lands in its deterministic slot' ); +durable_assert( 1073, $stag_beta->stagger_offset(), 'stagger: stag-beta lands in a different deterministic slot' ); +durable_assert( 1517, ( new WP_Agent_Routine( 'stag-alpha', array( 'agent' => 'commander', 'interval' => 3600 ) ) )->stagger_offset(), 'stagger: same id yields the same slot across constructions' ); + +$no_stagger = new WP_Agent_Routine( 'stag-alpha', array( 'agent' => 'commander', 'interval' => 3600, 'stagger' => false ) ); +durable_assert( 0, $no_stagger->stagger_offset(), 'stagger: stagger=false disables the offset' ); + +$bounded = new WP_Agent_Routine( 'stag-alpha', array( 'agent' => 'commander', 'interval' => 3600, 'stagger' => 120 ) ); +durable_assert( 77, $bounded->stagger_offset(), 'stagger: an int sets an explicit max window' ); + +$short_interval = new WP_Agent_Routine( 'stag-alpha', array( 'agent' => 'commander', 'interval' => 300 ) ); +durable_assert( 17, $short_interval->stagger_offset(), 'stagger: the window is capped by the interval' ); + +$cron_routine = new WP_Agent_Routine( 'stag-alpha', array( 'agent' => 'commander', 'expression' => '0 9 * * *' ) ); +durable_assert( 0, $cron_routine->stagger_offset(), 'stagger: cron expressions are not staggered' ); + +// --------------------------------------------------------------------------- +// 3. Registration stamps a generation and schedules with stagger. +// --------------------------------------------------------------------------- + +smoke_reset_state(); +WP_Agent_Routine_Registry::register( 'stag-alpha', array( 'agent' => 'commander', 'interval' => 3600 ) ); +WP_Agent_Routine_Registry::register( 'stag-beta', array( 'agent' => 'commander', 'interval' => 3600 ) ); + +$rows = array_values( smoke_pending_rows() ); +durable_assert( 2, count( $rows ), 'bridge: two interval routines scheduled' ); +durable_assert( true, isset( $rows[0]['timestamp'], $rows[1]['timestamp'] ), 'bridge: first-run timestamps recorded' ); +$delta = $rows[1]['timestamp'] - $rows[0]['timestamp']; +durable_assert( true, abs( $delta - ( 1073 - 1517 ) ) <= 1, 'bridge: first-run timestamps differ by the stagger delta' ); + +$gen_alpha = get_option( 'agents_routine_generation_stag-alpha', '' ); +durable_assert( true, is_string( $gen_alpha ) && '' !== $gen_alpha, 'bridge: register persists the generation option' ); +durable_assert( $gen_alpha, WP_Agent_Routine_Registry::current_generation( 'stag-alpha' ), 'registry: current_generation reads the persisted generation' ); +durable_assert( $gen_alpha, WP_Agent_Routine_Action_Identity::generation_from_args( $rows[0]['args'] ), 'bridge: scheduled args carry the generation stamp' ); +durable_assert( array( 'routine_id' => 'stag-alpha' ), WP_Agent_Routine_Action_Identity::logical_args( $rows[0]['args'] ), 'bridge: logical args stay stable under the stamp' ); + +// --------------------------------------------------------------------------- +// 4. Re-registration fences the superseded action instance. +// --------------------------------------------------------------------------- + +smoke_reset_state(); +WP_Agent_Routine_Registry::register( 'alpha', array( 'agent' => 'commander', 'interval' => 600, 'prompt' => 'Tick.' ) ); +$first_id = $GLOBALS['smoke_as_next_id']; +$gen_one = get_option( 'agents_routine_generation_alpha', '' ); + +// A worker claims the old action, then the routine is re-registered with a +// new interval (new generation) before the claimed instance executes. +WP_Agent_Routine_Registry::register( 'alpha', array( 'agent' => 'commander', 'interval' => 1200, 'prompt' => 'Tick.' ) ); +$gen_two = get_option( 'agents_routine_generation_alpha', '' ); + +durable_assert( true, '' !== $gen_one && '' !== $gen_two && $gen_one !== $gen_two, 'fencing: re-registration advances the generation' ); +durable_assert( ActionScheduler_Store::STATUS_CANCELED, $GLOBALS['smoke_as'][ $first_id ]['status'], 'fencing: re-registration unschedules the superseded action' ); + +$claimed = ActionScheduler_Store::instance()->fetch_action( $first_id ); +durable_assert( true, $claimed instanceof WP_Agent_Generation_Fenced_Action, 'fencing: fetched routine action is wrapped' ); +durable_assert( false, $claimed->get_schedule()->is_recurring(), 'fencing: a stale action reports a non-recurring schedule' ); + +$before = count( $GLOBALS['smoke_chat_ability']->calls ); +$claimed->execute(); +durable_assert( $before, count( $GLOBALS['smoke_chat_ability']->calls ), 'fencing: a stale action does not fire the routine callback' ); +durable_assert( true, smoke_hook_fired( 'agents_routine_action_fenced' ), 'fencing: the stale execution is reported via agents_routine_action_fenced' ); + +// The live (current-generation) action executes normally. +$live_rows = array_keys( smoke_pending_rows() ); +durable_assert( 1, count( $live_rows ), 'fencing: exactly one live chain remains after re-registration' ); +$live = ActionScheduler_Store::instance()->fetch_action( $live_rows[0] ); +$live->execute(); +durable_assert( $before + 1, count( $GLOBALS['smoke_chat_ability']->calls ), 'fencing: the current-generation action fires the routine callback' ); +durable_assert( 'commander', $GLOBALS['smoke_chat_ability']->calls[0]['agent'], 'listener: dispatched through the routine agent' ); +durable_assert( 'routine:alpha', $GLOBALS['smoke_chat_ability']->calls[0]['session_id'], 'listener: dispatched with the persistent routine session' ); + +// The listener also resolves stamped args handed over as a full array. +$listener = 'AgentsAPI\AI\Routines\dispatch_scheduled_routine_run'; +$listener( WP_Agent_Routine_Action_Identity::with_generation( array( 'routine_id' => 'alpha' ), $gen_two ) ); +durable_assert( $before + 2, count( $GLOBALS['smoke_chat_ability']->calls ), 'listener: stamped array args resolve via logical identity' ); + +// --------------------------------------------------------------------------- +// 5. The stored-successor reconciler cancels stale recurrence clones. +// --------------------------------------------------------------------------- + +smoke_reset_state(); +WP_Agent_Routine_Registry::register( 'beta', array( 'agent' => 'commander', 'interval' => 600 ) ); +$gen_beta = get_option( 'agents_routine_generation_beta', '' ); + +// AS repeat() clones the executed action's args into the successor; simulate +// a successor stored with a stale generation (old chain finishing late). +$stale_successor = smoke_as_save( + WP_Agent_Routine_Action_Scheduler_Bridge::SCHEDULED_HOOK, + WP_Agent_Routine_Action_Identity::with_generation( array( 'routine_id' => 'beta' ), 'stale-gen-0' ), + WP_Agent_Routine_Action_Scheduler_Bridge::GROUP, + time() + 600, + 600 +); +durable_assert( ActionScheduler_Store::STATUS_CANCELED, $GLOBALS['smoke_as'][ $stale_successor ]['status'], 'successor: a stale-generation recurrence clone is cancelled on store' ); + +// A successor carrying the current generation is left alone. +$live_successor = smoke_as_save( + WP_Agent_Routine_Action_Scheduler_Bridge::SCHEDULED_HOOK, + WP_Agent_Routine_Action_Identity::with_generation( array( 'routine_id' => 'beta' ), (string) $gen_beta ), + WP_Agent_Routine_Action_Scheduler_Bridge::GROUP, + time() + 600, + 600 +); +durable_assert( ActionScheduler_Store::STATUS_PENDING, $GLOBALS['smoke_as'][ $live_successor ]['status'], 'successor: a current-generation recurrence clone is preserved' ); + +// --------------------------------------------------------------------------- +// 6. Reconcile: missing coverage, orphans, dry runs, pause, and the lock. +// --------------------------------------------------------------------------- + +smoke_reset_state(); +WP_Agent_Routine_Registry::register( 'alpha', array( 'agent' => 'commander', 'interval' => 600 ) ); +WP_Agent_Routine_Registry::register( 'beta', array( 'agent' => 'commander', 'interval' => 600 ) ); + +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( array(), $result['enqueued'], 'reconcile: fully-covered registry enqueues nothing' ); +durable_assert( array( 'alpha', 'beta' ), $result['unchanged'], 'reconcile: covered routines report unchanged' ); +durable_assert( array(), $result['errors'], 'reconcile: no errors on a healthy registry' ); +durable_assert( false, get_option( 'agents_routine_reconcile_lock', false ), 'reconcile: the lock is released after the run' ); + +// Delete the pending action (AS table pruned) → reconcile restores it. +foreach ( smoke_pending_rows() as $id => $row ) { + if ( 'alpha' === ( $row['args']['routine_id'] ?? '' ) ) { + unset( $GLOBALS['smoke_as'][ $id ] ); + } +} +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( array( 'alpha' ), $result['enqueued'], 'reconcile: a missing routine schedule is re-enqueued' ); +durable_assert( array( 'beta' ), $result['unchanged'], 'reconcile: untouched routines stay unchanged' ); +durable_assert( 2, count( smoke_pending_rows() ), 'reconcile: the restored schedule is pending again' ); + +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( array( 'alpha', 'beta' ), $result['unchanged'], 'reconcile: a second run is idempotent' ); + +// Orphan: a pending action whose routine_id is not registered is removed. +smoke_as_save( + WP_Agent_Routine_Action_Scheduler_Bridge::SCHEDULED_HOOK, + array( 'routine_id' => 'ghost' ), + WP_Agent_Routine_Action_Scheduler_Bridge::GROUP, + time() + 600, + 600 +); +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( array( 'ghost' ), $result['removed'], 'reconcile: an orphaned routine action is removed' ); +durable_assert( 2, count( smoke_pending_rows() ), 'reconcile: registered routines survive orphan cleanup' ); + +// Dry run: report without writing. +foreach ( smoke_pending_rows() as $id => $row ) { + if ( 'beta' === ( $row['args']['routine_id'] ?? '' ) ) { + unset( $GLOBALS['smoke_as'][ $id ] ); + } +} +$gen_before = get_option( 'agents_routine_generation_beta', '' ); +$result = WP_Agent_Routine_Registry::reconcile( array( 'dry_run' => true ) ); +durable_assert( array( 'beta' ), $result['enqueued'], 'reconcile: dry run reports the missing schedule' ); +durable_assert( 1, count( smoke_pending_rows() ), 'reconcile: dry run writes no scheduled action' ); +durable_assert( $gen_before, get_option( 'agents_routine_generation_beta', '' ), 'reconcile: dry run does not advance the generation' ); +durable_assert( false, get_option( 'agents_routine_reconcile_lock', false ), 'reconcile: dry run takes no lock' ); + +// Paused routines are intentionally uncovered: reconcile must not re-enqueue. +WP_Agent_Routine_Registry::register( 'delta-ops', array( 'agent' => 'commander', 'interval' => 600 ) ); +WP_Agent_Routine_Registry::pause( 'delta-ops' ); +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( false, in_array( 'delta-ops', $result['enqueued'], true ), 'reconcile: a paused routine is not re-enqueued' ); +durable_assert( array( 'beta' ), $result['enqueued'], 'reconcile: the still-missing active routine is enqueued' ); + +// Lock: a fresh held lock blocks; a stale lock is taken over. +add_option( 'agents_routine_reconcile_lock', time(), '', false ); +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( true, isset( $result['errors']['_lock'] ), 'reconcile: a held lock blocks a concurrent run' ); + +update_option( 'agents_routine_reconcile_lock', time() - 400 ); +$result = WP_Agent_Routine_Registry::reconcile(); +durable_assert( array(), $result['errors'], 'reconcile: a stale lock is taken over' ); + +// --------------------------------------------------------------------------- +// 7. Unregister tears down the schedule and the generation tombstone. +// --------------------------------------------------------------------------- + +smoke_reset_state(); +WP_Agent_Routine_Registry::register( 'alpha', array( 'agent' => 'commander', 'interval' => 600 ) ); +WP_Agent_Routine_Registry::unregister( 'alpha' ); +durable_assert( '', get_option( 'agents_routine_generation_alpha', '' ), 'unregister: the generation option is deleted' ); +durable_assert( 0, count( smoke_pending_rows() ), 'unregister: the scheduled action is cancelled' ); + +// --------------------------------------------------------------------------- + +if ( count( $failures ) > 0 ) { + echo 'FAIL ' . count( $failures ) . " failures\n"; + exit( 1 ); +} +echo "OK {$passes} passed\n";