Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion docs/channels-workflows-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<routine_id>` 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_<action_id>`.
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_<routine_id>` 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_<action_id>`.

Two Action Scheduler hooks carry the mechanism:

Expand Down
123 changes: 93 additions & 30 deletions src/Routines/class-wp-agent-routine-action-scheduler-bridge.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
<?php
/**
* Optional Action Scheduler bridge for routines.
* Action Scheduler backend for routines.
*
* 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 logical args array so the listener can resolve the routine on wake.
* The default {@see WP_Agent_Routine_Backend} implementation: agents-api
* does not require Action Scheduler. When AS is available we register one
* recurring (or cron-expression) action per routine with a stable logical
* args array so the listener can resolve the routine on wake. The registry
* resolves this backend through `WP_Agent_Routine_Registry::backend()`.
*
* Deprecated statics: the pre-0.11.0 static facade for the interface
* methods (register/unregister/pause/resume/run_now/is_available/
* is_paused/current_generation) had to be removed — PHP cannot carry a
* static and an instance method of the same name, and the instance forms
* are required by the interface. Resolve the backend through
* `WP_Agent_Routine_Registry::backend()` instead. The AS-specific statics
* that do not collide with the interface (`pending_routine_actions()`,
* `cancel_action_by_id()`, the option helpers, and the fence installers)
* remain, the first two as thin deprecation shims.
*
* Durability behaviors layered on top:
*
Expand Down Expand Up @@ -33,7 +44,7 @@

defined( 'ABSPATH' ) || exit;

final class WP_Agent_Routine_Action_Scheduler_Bridge {
final class WP_Agent_Routine_Action_Scheduler_Bridge implements WP_Agent_Routine_Backend {

public const SCHEDULED_HOOK = 'wp_agent_routine_run_scheduled';

Expand All @@ -43,9 +54,17 @@ final class WP_Agent_Routine_Action_Scheduler_Bridge {
private const ACTION_GENERATION_OPTION_PREFIX = 'agents_routine_action_generation_';
private const PAUSED_OPTION = 'agents_routine_paused';

private static ?self $instance = null;

private static bool $fence_registered = false;

public static function is_available(): bool {
private function __construct() {}

public static function instance(): self {
return self::$instance ??= new self();
}

public function is_available(): bool {
return function_exists( 'as_schedule_recurring_action' )
&& function_exists( 'as_schedule_cron_action' )
&& function_exists( 'as_unschedule_all_actions' );
Expand All @@ -62,7 +81,7 @@ public static function generation_option_name( string $routine_id ): string {
* 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 {
public function current_generation( string $routine_id ): ?string {
if ( ! function_exists( 'get_option' ) ) {
return null;
}
Expand All @@ -74,7 +93,7 @@ public static function current_generation( string $routine_id ): ?string {
/**
* Whether the routine was durably paused via {@see pause()}.
*/
public static function is_paused( string $routine_id ): bool {
public function is_paused( string $routine_id ): bool {
if ( ! function_exists( 'get_option' ) ) {
return false;
}
Expand All @@ -96,9 +115,9 @@ public static function is_paused( string $routine_id ): bool {
* `wp_agent_routine_schedule_requested` hook was fired
* even without AS); false on no-op.
*/
public static function register( WP_Agent_Routine $routine ): bool {
public function register( WP_Agent_Routine $routine ): bool {
/**
* Fires whenever the bridge would schedule a routine, regardless
* Fires whenever the backend would schedule a routine, regardless
* of whether Action Scheduler is loaded. Custom schedulers can
* hook this to take over.
*
Expand All @@ -109,7 +128,7 @@ public static function register( WP_Agent_Routine $routine ): bool {
// Registration implies the routine is active.
self::set_paused( $routine->get_id(), false );

if ( ! self::is_available() ) {
if ( ! $this->is_available() ) {
return false;
}

Expand Down Expand Up @@ -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 );
Expand All @@ -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 );
Expand All @@ -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 );
}

/**
Expand All @@ -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;
}

Expand All @@ -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<int,\ActionScheduler_Action> 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
Expand All @@ -215,7 +245,7 @@ public static function run_now( WP_Agent_Routine $routine ): bool {
*
* @return array<int,\ActionScheduler_Action> 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();
}
Expand Down Expand Up @@ -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<string, list<int>> 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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 );
}
}
Expand Down
Loading