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";