diff --git a/agents-api.php b/agents-api.php index fd69b81..2128079 100644 --- a/agents-api.php +++ b/agents-api.php @@ -341,6 +341,7 @@ 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/interface-wp-agent-routine-backend.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'; diff --git a/composer.json b/composer.json index 62833a8..c4286dd 100644 --- a/composer.json +++ b/composer.json @@ -155,6 +155,7 @@ "php tests/agents-workflow-ability-smoke.php", "php tests/routine-smoke.php", "php tests/routines-durability-smoke.php", + "php tests/routines-backend-contract-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 d427f9b..f698386 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -323,9 +323,13 @@ 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 backends + +Durable scheduling sits behind a contract: `WP_Agent_Routine_Backend` (in `src/Routines/`) is the only scheduling surface the registry consumes — availability, register/unregister, pause/resume, run-now, pause-state and generation reads, and `pending_by_routine()` / `cancel()` for reconcile (handles are opaque ints). The registry resolves one backend per request through the `wp_agent_routine_backend` filter; the default is the Action Scheduler bridge (`WP_Agent_Routine_Action_Scheduler_Bridge`) when Action Scheduler is present, and `null` otherwise. With no backend, routines are still registered and their lifecycle hooks still fire, but nothing is scheduled and `reconcile()` reports a `_scheduler` error. Consumers replace the backend by filtering in their own `WP_Agent_Routine_Backend` implementation. + ### Routine generation fencing -Every `WP_Agent_Routine_Action_Scheduler_Bridge::register()` mints a schedule generation (`wp_generate_uuid4()`) and persists it in the non-autoloaded `agents_routine_generation_` option. Scheduled action args stay purely logical — `array( 'routine_id' => ... )` — so Action Scheduler's exact-match queries (`as_unschedule_all_actions`, `as_next_scheduled_action`) keep working and `register()` stays O(1) regardless of how many routines exist. The generation is recorded **per stored action**, keyed by action id, in `agents_routine_action_generation_`. +Every `WP_Agent_Routine_Action_Scheduler_Bridge` register call mints a schedule generation (`wp_generate_uuid4()`) and persists it in the non-autoloaded `agents_routine_generation_` option. Scheduled action args stay purely logical — `array( 'routine_id' => ... )` — so Action Scheduler's exact-match queries (`as_unschedule_all_actions`, `as_next_scheduled_action`) keep working and `register()` stays O(1) regardless of how many routines exist. The generation is recorded **per stored action**, keyed by action id, in `agents_routine_action_generation_`. Two Action Scheduler hooks carry the mechanism: 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 3fffab4..59ca477 100644 --- a/src/Routines/class-wp-agent-routine-action-scheduler-bridge.php +++ b/src/Routines/class-wp-agent-routine-action-scheduler-bridge.php @@ -1,11 +1,22 @@ get_id(), false ); - if ( ! self::is_available() ) { + if ( ! $this->is_available() ) { return false; } @@ -149,16 +168,16 @@ public static function register( WP_Agent_Routine $routine ): bool { } /** - * Cancel every scheduled action this bridge owns for the given routine, + * Cancel every scheduled action this backend owns for the given routine, * remove its generation tombstone, and clear any paused marker. */ - public static function unregister( string $routine_id ): void { + public 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() ) { + if ( ! $this->is_available() ) { return; } as_unschedule_all_actions( self::SCHEDULED_HOOK, array( 'routine_id' => $routine_id ), self::GROUP ); @@ -170,9 +189,9 @@ public static function unregister( string $routine_id ): void { * {@see WP_Agent_Routine_Registry::reconcile()} does not re-enqueue a * deliberately-paused routine. */ - public static function pause( string $routine_id ): void { + public function pause( string $routine_id ): void { self::set_paused( $routine_id, true ); - if ( ! self::is_available() ) { + if ( ! $this->is_available() ) { return; } as_unschedule_all_actions( self::SCHEDULED_HOOK, array( 'routine_id' => $routine_id ), self::GROUP ); @@ -184,8 +203,8 @@ public static function pause( string $routine_id ): void { * active simply re-registers (the underlying register call unschedules * first). */ - public static function resume( WP_Agent_Routine $routine ): bool { - return self::register( $routine ); + public function resume( WP_Agent_Routine $routine ): bool { + return $this->register( $routine ); } /** @@ -194,8 +213,8 @@ public static function resume( WP_Agent_Routine $routine ): bool { * 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' ) ) { + public function run_now( WP_Agent_Routine $routine ): bool { + if ( ! $this->is_available() || ! function_exists( 'as_enqueue_async_action' ) ) { return false; } @@ -207,6 +226,17 @@ public static function run_now( WP_Agent_Routine $routine ): bool { return true; } + /** + * All pending routine actions, hydrated from the store. + * + * @deprecated 0.11.0 Use WP_Agent_Routine_Registry::backend()->pending_by_routine(). + * + * @return array Pending actions keyed by action id. + */ + public static function pending_routine_actions(): array { + return self::instance()->hydrate_pending_actions(); + } + /** * All pending actions under the routine hook/group, hydrated from the * store. This is the one bulk scan in the module and belongs to @@ -215,7 +245,7 @@ public static function run_now( WP_Agent_Routine $routine ): bool { * * @return array Pending actions keyed by action id. */ - public static function pending_routine_actions(): array { + private function hydrate_pending_actions(): array { if ( ! function_exists( 'as_get_scheduled_actions' ) || ! class_exists( '\ActionScheduler_Store' ) ) { return array(); } @@ -252,23 +282,56 @@ public static function pending_routine_actions(): array { } /** - * Cancel one stored action by id. Returns true when the cancel call - * succeeded (or at least did not throw). + * Pending backend handles grouped by logical routine id. + * + * @return array> routine_id => pending action ids. + */ + public function pending_by_routine(): array { + $by_routine = array(); + foreach ( $this->pending_routine_actions() as $action_id => $action ) { + $args = $action->get_args(); + $routine_id = $args['routine_id'] ?? ( $args[0] ?? '' ); + if ( ! is_string( $routine_id ) || '' === $routine_id ) { + continue; + } + $by_routine[ $routine_id ][] = (int) $action_id; + } + return $by_routine; + } + + /** + * Cancel one stored action by id. + * + * @deprecated 0.11.0 Use WP_Agent_Routine_Registry::backend()->cancel(). + * + * @param int $action_id Action Scheduler action id. + * @return bool */ public static function cancel_action_by_id( int $action_id ): bool { - if ( $action_id <= 0 || ! class_exists( '\ActionScheduler_Store' ) ) { + return self::instance()->cancel( $action_id ); + } + + /** + * Cancel one pending action handle. Returns true when the cancel call + * succeeded (or at least did not throw). + * + * @param int $handle The opaque backend handle to cancel. + * @return bool + */ + public function cancel( int $handle ): bool { + if ( $handle <= 0 || ! class_exists( '\ActionScheduler_Store' ) ) { return false; } try { - \ActionScheduler_Store::instance()->cancel_action( $action_id ); + \ActionScheduler_Store::instance()->cancel_action( $handle ); } catch ( \Throwable $error ) { unset( $error ); return false; } if ( function_exists( 'delete_option' ) ) { - delete_option( self::action_generation_option_name( $action_id ) ); + delete_option( self::action_generation_option_name( $handle ) ); } return true; @@ -332,7 +395,7 @@ public static function stamp_stored_action( $action_id ): void { if ( '' === $routine_id ) { return; } - $generation = self::current_generation( $routine_id ); + $generation = self::instance()->current_generation( $routine_id ); if ( null === $generation ) { return; } @@ -357,11 +420,11 @@ public static function fence_before_execute( $action_id ): void { return; } $stamped = self::action_generation( $action_id ); - $current = self::current_generation( $routine_id ); + $current = self::instance()->current_generation( $routine_id ); if ( null === $stamped || ( null !== $current && hash_equals( $stamped, $current ) ) ) { return; // Unstamped (legacy) or current: let it run. } - if ( self::cancel_action_by_id( $action_id ) ) { + if ( self::instance()->cancel( $action_id ) ) { do_action( 'agents_routine_action_fenced', $routine_id, $stamped, $action_id ); } } diff --git a/src/Routines/class-wp-agent-routine-registry.php b/src/Routines/class-wp-agent-routine-registry.php index 8902b7a..08d8935 100644 --- a/src/Routines/class-wp-agent-routine-registry.php +++ b/src/Routines/class-wp-agent-routine-registry.php @@ -5,7 +5,7 @@ * Mirrors {@see WP_Agent_Workflow_Registry}: plugins call * {@see wp_register_routine()} during boot, the substrate keeps the * resolved Routine in process memory for the duration of the request, and - * the Action Scheduler bridge (separate file) reads the registry to + * the resolved scheduling backend (separate files) reads the registry to * (re-)register cron schedules on each plugin load. * * Like the workflow registry, this is stateless across requests — not a @@ -39,6 +39,17 @@ final class WP_Agent_Routine_Registry { */ private static array $routines = array(); + /** + * The routine scheduling backend, resolved once per request by + * {@see backend()}. + */ + private static ?WP_Agent_Routine_Backend $backend = null; + + /** + * Whether {@see backend()} has resolved (the resolved value may be null). + */ + private static bool $backend_resolved = false; + /** * @param array $args See {@see WP_Agent_Routine::__construct()}. * @return WP_Agent_Routine|WP_Error @@ -54,8 +65,8 @@ public static function register( string $id, array $args ) { /** * Fires after a routine is added to the in-memory registry. The - * Action Scheduler bridge subscribes to this hook to (re-)register - * the cron schedule. + * resolved scheduling backend subscribes to this hook to + * (re-)register the cron schedule. * * @since 0.105.0 * @@ -81,7 +92,7 @@ public static function unregister( string $routine_id ) { /** * Fires after a routine is removed from the in-memory registry. The - * AS bridge subscribes to cancel the matching schedule. + * resolved backend subscribes to cancel the matching schedule. * * @since 0.105.0 * @@ -97,11 +108,10 @@ 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. * - * 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. + * The backend records paused ids durably 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 backend. * * @since 0.106.0 * @@ -117,8 +127,8 @@ public static function pause( string $routine_id ) { $routine = self::$routines[ $routine_id ]; /** - * Fires when a caller requests pausing a routine. The Action Scheduler - * bridge listens to cancel the schedule (without unregistering). + * Fires when a caller requests pausing a routine. The resolved + * backend listens to cancel the schedule (without unregistering). * * @since 0.106.0 * @@ -133,7 +143,7 @@ public static function pause( string $routine_id ) { * Resume a previously-paused routine by re-establishing its schedule. * * Idempotent: resuming a routine whose schedule is still active just - * re-fires `wp_agent_routine_resumed`. The AS bridge's register call is + * re-fires `wp_agent_routine_resumed`. The backend's register call is * already idempotent (unschedules first), so the net effect is safe. * * @since 0.106.0 @@ -150,8 +160,9 @@ public static function resume( string $routine_id ) { $routine = self::$routines[ $routine_id ]; /** - * Fires when a caller requests resuming a paused routine. The AS - * bridge listens to re-register the recurring/cron schedule. + * Fires when a caller requests resuming a paused routine. The + * resolved backend listens to re-register the recurring/cron + * schedule. * * @since 0.106.0 * @@ -185,8 +196,8 @@ public static function run_now( string $routine_id ) { /** * Fires when a caller requests an immediate one-shot wake of a - * routine. The AS bridge listens to enqueue a single-action job for - * the same scheduled-hook the recurring schedule uses. + * routine. The resolved backend listens to enqueue a single-action + * job for the same scheduled hook the recurring schedule uses. * * @since 0.106.0 * @@ -202,23 +213,67 @@ public static function find( string $routine_id ): ?WP_Agent_Routine { } /** - * The routine's current schedule generation as persisted by the Action - * Scheduler bridge, or null when none exists. + * The resolved routine scheduling backend, or null when none is + * available. + * + * Resolved once per request: the default is the Action Scheduler bridge + * when Action Scheduler is present, and null otherwise. Consumers can + * substitute any {@see WP_Agent_Routine_Backend} implementation through + * the `wp_agent_routine_backend` filter; a filter return that is not a + * backend falls back to the default. + * + * @since 0.11.0 + */ + public static function backend(): ?WP_Agent_Routine_Backend { + if ( ! self::$backend_resolved ) { + $default = WP_Agent_Routine_Action_Scheduler_Bridge::instance()->is_available() + ? WP_Agent_Routine_Action_Scheduler_Bridge::instance() + : null; + + /** + * Filters the routine scheduling backend. + * + * @since 0.11.0 + * + * @param WP_Agent_Routine_Backend|null $default The default backend (the Action Scheduler bridge when available), or null. + */ + $filtered = apply_filters( 'wp_agent_routine_backend', $default ); + self::$backend = $filtered instanceof WP_Agent_Routine_Backend ? $filtered : $default; + self::$backend_resolved = true; + } + + return self::$backend; + } + + /** + * Test-only: forget the resolved backend so the next {@see backend()} + * call re-resolves it (re-running the `wp_agent_routine_backend` filter). + * + * @since 0.11.0 + */ + public static function reset_backend(): void { + self::$backend = null; + self::$backend_resolved = false; + } + + /** + * The routine's current schedule generation as persisted by the active + * backend, 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 ); + return self::backend()?->current_generation( $routine_id ); } /** - * Reconcile the in-memory registry against the Action Scheduler store. + * Reconcile the in-memory registry against the scheduling backend. * - * 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 + * Registry state and backend state drift: the backend's store can be + * pruned, a site can be restored from backup, scheduled work can be + * manually deleted. For every registered, non-paused routine this checks + * pending-handle coverage by logical identity (routine id) and registers + * a fresh schedule when coverage is missing; pending handles whose * logical routine_id is not registered (or is durably paused) are - * unscheduled as orphans. + * cancelled 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 @@ -231,12 +286,12 @@ public static function current_generation( string $routine_id ): ?string { public static function reconcile( array $opts = array() ): array { $dry_run = ! empty( $opts['dry_run'] ); - if ( ! WP_Agent_Routine_Action_Scheduler_Bridge::is_available() ) { + if ( null === self::backend() ) { return array( 'enqueued' => array(), 'removed' => array(), 'unchanged' => array(), - 'errors' => array( '_scheduler' => 'Action Scheduler is not available.' ), + 'errors' => array( '_scheduler' => 'No routine scheduling backend is available.' ), ); } @@ -269,22 +324,24 @@ private static function reconcile_unlocked( bool $dry_run ): 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; - } + $backend = self::backend(); + if ( null === $backend ) { + return array( + 'enqueued' => array(), + 'removed' => array(), + 'unchanged' => array(), + 'errors' => array( '_scheduler' => 'No routine scheduling backend is available.' ), + ); } + $pending = $backend->pending_by_routine(); + foreach ( self::$routines as $routine_id => $routine ) { - if ( WP_Agent_Routine_Action_Scheduler_Bridge::is_paused( $routine_id ) ) { + if ( $backend->is_paused( $routine_id ) ) { continue; } - if ( isset( $covered[ $routine_id ] ) ) { + if ( ! empty( $pending[ $routine_id ] ) ) { $unchanged[] = $routine_id; continue; } @@ -294,21 +351,16 @@ private static function reconcile_unlocked( bool $dry_run ): array { continue; } - if ( WP_Agent_Routine_Action_Scheduler_Bridge::register( $routine ) ) { + if ( $backend->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; - } - + foreach ( $pending as $routine_id => $handles ) { $orphan = ! isset( self::$routines[ $routine_id ] ) - || WP_Agent_Routine_Action_Scheduler_Bridge::is_paused( $routine_id ); + || $backend->is_paused( $routine_id ); if ( ! $orphan ) { continue; } @@ -318,7 +370,14 @@ private static function reconcile_unlocked( bool $dry_run ): array { continue; } - if ( WP_Agent_Routine_Action_Scheduler_Bridge::cancel_action_by_id( (int) $action_id ) ) { + $cancelled_all = true; + foreach ( $handles as $handle ) { + if ( ! $backend->cancel( (int) $handle ) ) { + $cancelled_all = false; + } + } + + if ( $cancelled_all ) { $removed[] = $routine_id; } else { $errors[ $routine_id ] = 'Failed to remove the orphaned routine schedule.'; @@ -333,16 +392,6 @@ private static function reconcile_unlocked( bool $dry_run ): array { ); } - /** - * Resolve the routine id out of stored action args. - * - * @param array $args Stored action args. - */ - private static function logical_routine_id( array $args ): string { - $value = $args['routine_id'] ?? ( $args[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 diff --git a/src/Routines/interface-wp-agent-routine-backend.php b/src/Routines/interface-wp-agent-routine-backend.php new file mode 100644 index 0000000..9b23546 --- /dev/null +++ b/src/Routines/interface-wp-agent-routine-backend.php @@ -0,0 +1,135 @@ +> routine_id => pending backend handles. + */ + public function pending_by_routine(): array; + + /** + * Cancel one pending schedule handle previously returned by + * {@see pending_by_routine()}. + * + * @since 0.11.0 + * + * @param int $handle The opaque backend handle to cancel. + * @return bool True when the cancel succeeded (or at least did not fail). + */ + public function cancel( int $handle ): bool; +} diff --git a/src/Routines/register-action-scheduler-listener.php b/src/Routines/register-action-scheduler-listener.php index d4795e8..0cacb7d 100644 --- a/src/Routines/register-action-scheduler-listener.php +++ b/src/Routines/register-action-scheduler-listener.php @@ -82,7 +82,6 @@ function dispatch_scheduled_routine_run( $args ): void { // listener. $grant = static fn() => true; add_filter( 'agents_chat_permission', $grant ); - add_filter( 'openclawp_chat_ability_permission', $grant ); try { $result = $chat->execute( array( @@ -92,7 +91,6 @@ function dispatch_scheduled_routine_run( $args ): void { ) ); } finally { - remove_filter( 'openclawp_chat_ability_permission', $grant ); remove_filter( 'agents_chat_permission', $grant ); } diff --git a/src/Routines/register-routine-bridge-sync.php b/src/Routines/register-routine-bridge-sync.php index 932a242..4822d4b 100644 --- a/src/Routines/register-routine-bridge-sync.php +++ b/src/Routines/register-routine-bridge-sync.php @@ -1,13 +1,19 @@ register( $routine ); }, 10, 1 @@ -29,7 +35,7 @@ static function ( WP_Agent_Routine $routine ): void { add_action( 'wp_agent_routine_unregistered', static function ( WP_Agent_Routine $routine ): void { - WP_Agent_Routine_Action_Scheduler_Bridge::unregister( $routine->get_id() ); + WP_Agent_Routine_Registry::backend()?->unregister( $routine->get_id() ); }, 10, 1 @@ -38,7 +44,7 @@ static function ( WP_Agent_Routine $routine ): void { add_action( 'wp_agent_routine_paused', static function ( WP_Agent_Routine $routine ): void { - WP_Agent_Routine_Action_Scheduler_Bridge::pause( $routine->get_id() ); + WP_Agent_Routine_Registry::backend()?->pause( $routine->get_id() ); }, 10, 1 @@ -47,7 +53,7 @@ static function ( WP_Agent_Routine $routine ): void { add_action( 'wp_agent_routine_resumed', static function ( WP_Agent_Routine $routine ): void { - WP_Agent_Routine_Action_Scheduler_Bridge::resume( $routine ); + WP_Agent_Routine_Registry::backend()?->resume( $routine ); }, 10, 1 @@ -56,7 +62,7 @@ static function ( WP_Agent_Routine $routine ): void { add_action( 'wp_agent_routine_run_now_requested', static function ( WP_Agent_Routine $routine ): void { - WP_Agent_Routine_Action_Scheduler_Bridge::run_now( $routine ); + WP_Agent_Routine_Registry::backend()?->run_now( $routine ); }, 10, 1 @@ -64,5 +70,6 @@ static function ( WP_Agent_Routine $routine ): void { // 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. +// cancelled. This is Action-Scheduler-specific plumbing owned by the +// default backend; the callbacks no-op when Action Scheduler is absent. WP_Agent_Routine_Action_Scheduler_Bridge::register_generation_fence(); diff --git a/tests/routine-smoke.php b/tests/routine-smoke.php index 22ac250..b7744ec 100644 --- a/tests/routine-smoke.php +++ b/tests/routine-smoke.php @@ -111,6 +111,7 @@ function as_enqueue_async_action( string $hook, array $args = array(), string $g } require_once __DIR__ . '/../src/Routines/class-wp-agent-routine.php'; +require_once __DIR__ . '/../src/Routines/interface-wp-agent-routine-backend.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'; @@ -244,7 +245,7 @@ function as_enqueue_async_action( string $hook, array $args = array(), string $g // 7. Action Scheduler bridge uses one stable associative args shape. $GLOBALS['routine_as_calls'] = array(); -$scheduled_routine = new WP_Agent_Routine( +$scheduled_routine = new WP_Agent_Routine( 'daily-check', array( 'agent' => 'commander', @@ -252,9 +253,9 @@ function as_enqueue_async_action( string $hook, array $args = array(), string $g ) ); -WP_Agent_Routine_Action_Scheduler_Bridge::register( $scheduled_routine ); -WP_Agent_Routine_Action_Scheduler_Bridge::unregister( 'daily-check' ); -WP_Agent_Routine_Action_Scheduler_Bridge::run_now( $scheduled_routine ); +WP_Agent_Routine_Action_Scheduler_Bridge::instance()->register( $scheduled_routine ); +WP_Agent_Routine_Action_Scheduler_Bridge::instance()->unregister( 'daily-check' ); +WP_Agent_Routine_Action_Scheduler_Bridge::instance()->run_now( $scheduled_routine ); $args_for_call = static function ( string $fn, int $index ): ?array { $matches = array_values( array_filter( diff --git a/tests/routines-backend-contract-smoke.php b/tests/routines-backend-contract-smoke.php new file mode 100644 index 0000000..884aa7a --- /dev/null +++ b/tests/routines-backend-contract-smoke.php @@ -0,0 +1,334 @@ +code; + } + public function get_error_message(): string { + return $this->message; + } + public function get_error_data() { + return $this->data; + } + } +} + +// Mini hook system (mirrors tests/routines-durability-smoke.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( '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 ); + } + } + } +} +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; + } +} + +// --------------------------------------------------------------------------- +// Module under test. No as_* functions exist, so the default backend is null. +// --------------------------------------------------------------------------- + +require_once __DIR__ . '/../src/Routines/class-wp-agent-routine.php'; +require_once __DIR__ . '/../src/Routines/interface-wp-agent-routine-backend.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'; + +use AgentsAPI\AI\Routines\WP_Agent_Routine; +use AgentsAPI\AI\Routines\WP_Agent_Routine_Backend; +use AgentsAPI\AI\Routines\WP_Agent_Routine_Registry; + +/** + * Fake in-memory backend recording every contract call. + */ +class Contract_Fake_Backend implements WP_Agent_Routine_Backend { + + /** @var list */ + public array $calls = array(); + + /** @var list */ + public array $registered_routines = array(); + + /** @var array> */ + public array $pending = array(); + + /** @var list */ + public array $paused_ids = array(); + + private int $next_handle = 100; + + public function is_available(): bool { + $this->calls[] = 'is_available'; + return true; + } + + public function register( WP_Agent_Routine $routine ): bool { + $this->calls[] = 'register:' . $routine->get_id(); + $this->registered_routines[] = $routine; + $this->pending[ $routine->get_id() ][] = ++$this->next_handle; + return true; + } + + public function unregister( string $routine_id ): void { + $this->calls[] = 'unregister:' . $routine_id; + unset( $this->pending[ $routine_id ] ); + } + + public function pause( string $routine_id ): void { + $this->calls[] = 'pause:' . $routine_id; + $this->paused_ids[] = $routine_id; + } + + public function resume( WP_Agent_Routine $routine ): bool { + $this->calls[] = 'resume:' . $routine->get_id(); + $this->paused_ids = array_values( array_diff( $this->paused_ids, array( $routine->get_id() ) ) ); + return $this->register( $routine ); + } + + public function run_now( WP_Agent_Routine $routine ): bool { + $this->calls[] = 'run_now:' . $routine->get_id(); + return true; + } + + public function is_paused( string $routine_id ): bool { + return in_array( $routine_id, $this->paused_ids, true ); + } + + public function current_generation( string $routine_id ): ?string { + unset( $routine_id ); + return null; + } + + public function pending_by_routine(): array { + $this->calls[] = 'pending_by_routine'; + return $this->pending; + } + + public function cancel( int $handle ): bool { + $this->calls[] = 'cancel:' . $handle; + foreach ( $this->pending as $routine_id => $handles ) { + $index = array_search( $handle, $handles, true ); + if ( false !== $index ) { + unset( $this->pending[ $routine_id ][ $index ] ); + $this->pending[ $routine_id ] = array_values( $this->pending[ $routine_id ] ); + if ( array() === $this->pending[ $routine_id ] ) { + unset( $this->pending[ $routine_id ] ); + } + return true; + } + } + return false; + } + + /** + * @return list + */ + public function calls_of( string $prefix ): array { + return array_values( + array_filter( + $this->calls, + static fn( string $call ): bool => str_starts_with( $call, $prefix ) + ) + ); + } +} + +// The filter reads a global so a single registration can swap backends. +$GLOBALS['contract_backend'] = null; +add_filter( + 'wp_agent_routine_backend', + static fn() => $GLOBALS['contract_backend'] +); + +function contract_reset( ?Contract_Fake_Backend $backend ): void { + WP_Agent_Routine_Registry::reset(); + WP_Agent_Routine_Registry::reset_backend(); + $GLOBALS['contract_backend'] = $backend; +} + +// Sanity: with no filter return at all, the default backend is null (no +// as_* functions are defined in this process). +contract_reset( null ); +contract_assert( null, WP_Agent_Routine_Registry::backend(), 'default: no as_* functions means the default backend is null' ); + +// --------------------------------------------------------------------------- +// 1. A fake backend installed via the filter is what the registry resolves. +// --------------------------------------------------------------------------- + +contract_reset( new Contract_Fake_Backend() ); +$fake = $GLOBALS['contract_backend']; +contract_assert( true, WP_Agent_Routine_Registry::backend() instanceof WP_Agent_Routine_Backend, 'filter: the resolved backend is the fake' ); +contract_assert( true, WP_Agent_Routine_Registry::backend() === $fake, 'filter: the backend resolves to the exact fake instance' ); + +// --------------------------------------------------------------------------- +// 2. register() routes through the backend with the routine. +// --------------------------------------------------------------------------- + +$registered = WP_Agent_Routine_Registry::register( 'alpha', array( 'agent' => 'commander', 'interval' => 600 ) ); +contract_assert( true, $registered instanceof WP_Agent_Routine, 'register: the routine registers cleanly' ); +contract_assert( array( 'register:alpha' ), $fake->calls_of( 'register:' ), 'register: the backend register verb is called' ); +contract_assert( true, isset( $fake->registered_routines[0] ) && $fake->registered_routines[0] === $registered, 'register: the backend receives the exact routine instance' ); +contract_assert( true, ! empty( $fake->pending['alpha'] ), 'register: the backend records a pending handle for the routine' ); + +// --------------------------------------------------------------------------- +// 3. pause / resume / run_now route through the backend. +// --------------------------------------------------------------------------- + +contract_assert( true, WP_Agent_Routine_Registry::pause( 'alpha' ), 'pause: the registry verb returns true' ); +contract_assert( array( 'pause:alpha' ), $fake->calls_of( 'pause:' ), 'pause: the backend pause verb is called' ); + +contract_assert( true, WP_Agent_Routine_Registry::resume( 'alpha' ), 'resume: the registry verb returns true' ); +contract_assert( array( 'resume:alpha' ), $fake->calls_of( 'resume:' ), 'resume: the backend resume verb is called' ); +contract_assert( array(), $fake->paused_ids, 'resume: the fake clears the paused marker' ); + +contract_assert( true, WP_Agent_Routine_Registry::run_now( 'alpha' ), 'run_now: the registry verb returns true' ); +contract_assert( array( 'run_now:alpha' ), $fake->calls_of( 'run_now:' ), 'run_now: the backend run_now verb is called' ); + +// Unregister tears down through the backend too. +contract_assert( true, WP_Agent_Routine_Registry::unregister( 'alpha' ), 'unregister: the registry verb returns true' ); +contract_assert( array( 'unregister:alpha' ), $fake->calls_of( 'unregister:' ), 'unregister: the backend unregister verb is called' ); + +// --------------------------------------------------------------------------- +// 4. reconcile() works entirely off the contract reads/writes. +// --------------------------------------------------------------------------- + +// Re-register alpha (covered), register beta, then simulate drift: beta's +// schedule disappeared from the backend and a ghost handle is pending. +WP_Agent_Routine_Registry::register( 'alpha', array( 'agent' => 'commander', 'interval' => 600 ) ); +WP_Agent_Routine_Registry::register( 'beta', array( 'agent' => 'commander', 'interval' => 900 ) ); +unset( $fake->pending['beta'] ); +$fake->pending['ghost'] = array( 999 ); + +$fake->calls = array(); +$result = WP_Agent_Routine_Registry::reconcile(); + +contract_assert( true, in_array( 'pending_by_routine', $fake->calls, true ), 'reconcile: the backend bulk read is used' ); +contract_assert( array( 'register:beta' ), $fake->calls_of( 'register:' ), 'reconcile: the missing routine is enqueued through the backend' ); +contract_assert( array( 'cancel:999' ), $fake->calls_of( 'cancel:' ), 'reconcile: the orphaned handle is cancelled through the backend' ); +contract_assert( array( 'beta' ), $result['enqueued'], 'reconcile: enqueued reports the missing routine' ); +contract_assert( array( 'ghost' ), $result['removed'], 'reconcile: removed reports the orphan' ); +contract_assert( array( 'alpha' ), $result['unchanged'], 'reconcile: unchanged reports the covered routine' ); +contract_assert( array(), $result['errors'], 'reconcile: no errors on a healthy reconcile' ); + +// Dry run reports the same shape without writing through the backend. +unset( $fake->pending['beta'] ); +$fake->calls = array(); +$result = WP_Agent_Routine_Registry::reconcile( array( 'dry_run' => true ) ); +contract_assert( array(), $fake->calls_of( 'register:' ), 'reconcile: dry run writes nothing through the backend' ); +contract_assert( array(), $fake->calls_of( 'cancel:' ), 'reconcile: dry run cancels nothing through the backend' ); +contract_assert( array( 'beta' ), $result['enqueued'], 'reconcile: dry run reports the missing schedule' ); + +// A paused routine is intentionally uncovered: reconcile must not re-enqueue. +WP_Agent_Routine_Registry::register( 'delta-ops', array( 'agent' => 'commander', 'interval' => 300 ) ); +unset( $fake->pending['delta-ops'] ); +WP_Agent_Routine_Registry::pause( 'delta-ops' ); +$result = WP_Agent_Routine_Registry::reconcile(); +contract_assert( false, in_array( 'delta-ops', $result['enqueued'], true ), 'reconcile: a paused routine is not re-enqueued' ); +contract_assert( array( 'beta' ), $result['enqueued'], 'reconcile: the still-missing active routine is enqueued' ); + +// --------------------------------------------------------------------------- +// 5. A garbage filter return falls back to the default (null here). +// --------------------------------------------------------------------------- + +$GLOBALS['contract_backend'] = 'not-a-backend'; +WP_Agent_Routine_Registry::reset_backend(); +contract_assert( null, WP_Agent_Routine_Registry::backend(), 'fallback: a garbage filter return falls back to the default' ); + +$result = WP_Agent_Routine_Registry::reconcile(); +contract_assert( true, isset( $result['errors']['_scheduler'] ), 'fallback: reconcile reports the _scheduler error with no backend' ); +contract_assert( array(), $result['enqueued'], 'fallback: reconcile enqueues nothing with no backend' ); + +// Lifecycle verbs still succeed (the hooks fire; the sync listeners no-op). +contract_assert( true, WP_Agent_Routine_Registry::pause( 'alpha' ), 'fallback: pause still returns true with no backend' ); +contract_assert( true, WP_Agent_Routine_Registry::resume( 'alpha' ), 'fallback: resume still returns true with no backend' ); +contract_assert( true, WP_Agent_Routine_Registry::run_now( 'alpha' ), 'fallback: run_now still returns true with no backend' ); + +// The backend resolves lazily again once a real one is back. +contract_reset( new Contract_Fake_Backend() ); +contract_assert( true, WP_Agent_Routine_Registry::backend() instanceof WP_Agent_Routine_Backend, 'fallback: reset_backend() re-resolves the filter' ); + +// --------------------------------------------------------------------------- + +if ( count( $failures ) > 0 ) { + echo 'FAIL ' . count( $failures ) . " failures\n"; + exit( 1 ); +} +echo "OK {$passes} passed\n"; diff --git a/tests/routines-durability-smoke.php b/tests/routines-durability-smoke.php index d3a13c6..8d5e6f3 100644 --- a/tests/routines-durability-smoke.php +++ b/tests/routines-durability-smoke.php @@ -263,7 +263,7 @@ public static function instance(): self { /** @param int|string $action_id Action id. */ public function fetch_action( $action_id ): ActionScheduler_Action { $GLOBALS['smoke_as_fetches'][] = (int) $action_id; - $row = $GLOBALS['smoke_as'][ (int) $action_id ] ?? null; + $row = $GLOBALS['smoke_as'][ (int) $action_id ] ?? null; if ( null === $row ) { throw new RuntimeException( 'unknown action' ); } @@ -378,6 +378,7 @@ function as_get_scheduled_actions( array $query = array(), string $return_format // --------------------------------------------------------------------------- require_once __DIR__ . '/../src/Routines/class-wp-agent-routine.php'; +require_once __DIR__ . '/../src/Routines/interface-wp-agent-routine-backend.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'; @@ -390,6 +391,7 @@ function as_get_scheduled_actions( array $query = array(), string $return_format function smoke_reset_state(): void { $GLOBALS['smoke_as_fetches'] = array(); WP_Agent_Routine_Registry::reset(); + WP_Agent_Routine_Registry::reset_backend(); $GLOBALS['smoke_as'] = array(); $GLOBALS['smoke_as_next_id'] = 0; $GLOBALS['smoke_options'] = array();