Skip to content
Closed
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
3 changes: 3 additions & 0 deletions agents-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions docs/channels-workflows-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<routine_id>` 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/`:
Expand Down
124 changes: 124 additions & 0 deletions src/Routines/class-wp-agent-generation-fenced-action.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php
/**
* Action Scheduler action fenced by a persisted routine schedule generation.
*
* Action Scheduler is an optional runtime dependency: this file declares
* nothing when its base class is unavailable, so the bootstrap can require it
* unconditionally on sites without Action Scheduler.
*
* @package AgentsAPI
*/

namespace AgentsAPI\AI\Routines;

defined( 'ABSPATH' ) || exit;

if ( class_exists( '\ActionScheduler_Action' ) ) {

/**
* A fetched routine action that no-ops when its stamped generation no
* longer matches the routine's current generation.
*
* Two fences in one wrapper:
*
* - {@see execute()} refuses to fire the scheduled hook when the stamped
* generation is stale, so a superseded or orphaned scheduled action
* never wakes the routine.
* - {@see get_schedule()} reports a canceled (non-recurring) schedule when
* stale, so Action Scheduler's queue runner never clones a successor
* from a superseded recurring action in the same process.
*/
class WP_Agent_Generation_Fenced_Action extends \ActionScheduler_Action {

private string $routine_id;
private string $expected_generation;

/**
* @param string $hook Action hook.
* @param array<array-key,mixed> $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 );
}
}
}
84 changes: 84 additions & 0 deletions src/Routines/class-wp-agent-routine-action-identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php
/**
* Generation-aware Action Scheduler args identity for routines.
*
* The bridge stamps every scheduled routine action with the routine's current
* generation, appended as a trailing metadata element so the *logical* args
* (`array( 'routine_id' => ... )`) 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<array-key,mixed> $args Logical args.
* @param string $generation Current schedule generation.
* @return array<array-key,mixed>
*/
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<array-key,mixed> $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<array-key,mixed> $args Possibly-stamped args.
* @return array<array-key,mixed>
*/
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 ) );
}
}
Loading